diff --git a/Sources/OCCTBridge/include/OCCTBridge.h b/Sources/OCCTBridge/include/OCCTBridge.h index 6ac71445..401a2dc0 100644 --- a/Sources/OCCTBridge/include/OCCTBridge.h +++ b/Sources/OCCTBridge/include/OCCTBridge.h @@ -986,6 +986,13 @@ OCCTShapeRef OCCTImportSTEP(const char* path); // Cancellation: if shouldCancel returns true, OCCT stops at the next polling // boundary. The *Progress entry points return NULL and set *outCancelled=true. // If the import otherwise fails, NULL is returned and *outCancelled stays false. +// +// Both halves of that hold on every exit path, not only at the bridge's own +// checkpoints (#525): a break during a transfer surfaces as zero transferred +// roots, a null shape, a non-Done status or an exception depending on where it +// lands, and each of those is still reported as a cancellation rather than as a +// failure. One true from shouldCancel is also enough -- it is latched, so a +// caller that answers true once and false afterwards still stops the call. typedef struct OCCTImportProgress { /// Called as the importer advances. fraction is 0.0...1.0; step is a diff --git a/Sources/OCCTBridge/src/OCCTBridge_IO.mm b/Sources/OCCTBridge/src/OCCTBridge_IO.mm index d70ce483..54b34b71 100644 --- a/Sources/OCCTBridge/src/OCCTBridge_IO.mm +++ b/Sources/OCCTBridge/src/OCCTBridge_IO.mm @@ -55,6 +55,7 @@ #include #include #include +#include #include #include #include @@ -191,15 +192,29 @@ void Show(const Message_ProgressScope& theScope, const Standard_Boolean isForce) myCtx->onProgress(fraction, name, myCtx->userData); } + // Latches the break rather than re-asking. OCCT polls this from every scope that guards a + // loop, and the bridge polls it again at each phase boundary, so a caller that answers + // "cancel" once -- a one-shot flag, a Task.isCancelled read that has already been consumed -- + // used to have that answer overwritten by the next poll: the algorithm aborted, the later + // poll said "no break", and the call handed back its half-finished result as a success. + // The documented contract is that a single true stops the call (#525). Standard_Boolean UserBreak() override { + if (myBroken.load(std::memory_order_relaxed)) return Standard_True; if (!myCtx || !myCtx->shouldCancel) return Standard_False; - return myCtx->shouldCancel(myCtx->userData) ? Standard_True : Standard_False; + if (!myCtx->shouldCancel(myCtx->userData)) return Standard_False; + myBroken.store(true, std::memory_order_relaxed); + return Standard_True; } + // Whether a break was ever observed, without polling the caller again. std::atomic because + // OCCT documents UserBreak() as callable concurrently (Message_ProgressIndicator.hxx). + bool Cancelled() const { return myBroken.load(std::memory_order_relaxed); } + DEFINE_STANDARD_RTTI_INLINE(BridgeProgressIndicator, Message_ProgressIndicator) private: const OCCTImportProgress* myCtx; + std::atomic myBroken{false}; }; DEFINE_STANDARD_HANDLE(BridgeProgressIndicator, Message_ProgressIndicator) @@ -207,8 +222,18 @@ Standard_Boolean UserBreak() override { static inline void clearCancelOut(bool* outCancelled) { if (outCancelled) *outCancelled = false; } -static inline void setCancelOut(bool* outCancelled, opencascade::handle& ind) { - if (outCancelled && !ind.IsNull()) *outCancelled = ind->UserBreak() ? true : false; + +// Report a cancelled call as cancelled whichever exit it takes (#525). +// +// The explicit UserBreak() checkpoints are not the only way out of these functions: an aborted +// TransferRoots reports zero transferred roots, an aborted transfer can leave a null shape or a +// non-Done status behind, and a break raised inside OCCT arrives here as an exception. Those +// paths used to return "failed" for a call the caller had explicitly cancelled, so which error a +// caller saw depended on which phase the cancellation happened to land in. Every failure return +// below the indicator's construction therefore passes through this, and it reads the latch rather +// than polling again -- the answer belongs to the poll that actually stopped the work. +static inline void setCancelOut(bool* outCancelled, const opencascade::handle& ind) { + if (outCancelled) *outCancelled = !ind.IsNull() && ind->Cancelled(); } // Turn every shell a sewing produced into a solid, rather than only the first (#302). @@ -264,12 +289,14 @@ OCCTShapeRef OCCTImportSTEPProgress(const char* path, if (!path) return nullptr; // Serialize all DE reads: STEP/IGES share Interface_Static globals (#181-B, #359). std::lock_guard igesLock(igesMutex()); + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { STEPControl_Reader reader; IFSelect_ReturnStatus status = reader.ReadFile(path); if (status != IFSelect_RetDone) return nullptr; - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); reader.TransferRoots(range); if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return nullptr; } @@ -277,7 +304,7 @@ OCCTShapeRef OCCTImportSTEPProgress(const char* path, TopoDS_Shape shape = reader.OneShape(); if (shape.IsNull()) return nullptr; return new OCCTShape(shape); - } catch (...) { return nullptr; } + } catch (...) { setCancelOut(outCancelled, indicator); return nullptr; } } OCCTShapeRef OCCTImportSTEPRobustProgress(const char* path, @@ -287,6 +314,8 @@ OCCTShapeRef OCCTImportSTEPRobustProgress(const char* path, if (!path) return nullptr; // Serialize all DE reads: STEP/IGES share Interface_Static globals (#181-B, #359). std::lock_guard igesLock(igesMutex()); + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { STEPControl_Reader reader; Interface_Static::SetIVal("read.precision.mode", 0); @@ -297,11 +326,13 @@ OCCTShapeRef OCCTImportSTEPRobustProgress(const char* path, IFSelect_ReturnStatus status = reader.ReadFile(path); if (status != IFSelect_RetDone) return nullptr; - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); // Split the range: the repair phase below is comparable in cost to the transfer and // must stay within the caller's reach. See OCCTImportIGESRobustProgress (#300). Message_ProgressScope scope(indicator->Start(), "Import", 2); - if (reader.TransferRoots(scope.Next()) == 0) return nullptr; + // A break during the transfer leaves zero roots transferred, which is how a cancellation + // that lands in this phase reaches the caller -- as cancelled, not as a failed import (#525). + if (reader.TransferRoots(scope.Next()) == 0) { setCancelOut(outCancelled, indicator); return nullptr; } if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return nullptr; } TopoDS_Shape shape = reader.OneShape(); @@ -344,7 +375,7 @@ OCCTShapeRef OCCTImportSTEPRobustProgress(const char* path, if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return nullptr; } TopoDS_Shape fixed = fixer.Shape(); return new OCCTShape(fixed.IsNull() ? shape : fixed); - } catch (...) { return nullptr; } + } catch (...) { setCancelOut(outCancelled, indicator); return nullptr; } } OCCTShapeRef OCCTImportSTEPWithUnitProgress(const char* path, double unitInMeters, @@ -354,13 +385,15 @@ OCCTShapeRef OCCTImportSTEPWithUnitProgress(const char* path, double unitInMeter if (!path) return nullptr; // Serialize all DE reads: STEP/IGES share Interface_Static globals (#181-B, #359). std::lock_guard igesLock(igesMutex()); + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { STEPControl_Reader reader; reader.SetSystemLengthUnit(unitInMeters); IFSelect_ReturnStatus status = reader.ReadFile(path); if (status != IFSelect_RetDone) return nullptr; - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); reader.TransferRoots(range); if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return nullptr; } @@ -368,7 +401,7 @@ OCCTShapeRef OCCTImportSTEPWithUnitProgress(const char* path, double unitInMeter TopoDS_Shape shape = reader.OneShape(); if (shape.IsNull()) return nullptr; return new OCCTShape(shape); - } catch (...) { return nullptr; } + } catch (...) { setCancelOut(outCancelled, indicator); return nullptr; } } OCCTShapeRef OCCTImportIGESProgress(const char* path, @@ -377,12 +410,14 @@ OCCTShapeRef OCCTImportIGESProgress(const char* path, clearCancelOut(outCancelled); if (!path) return nullptr; std::lock_guard igesLock(igesMutex()); + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { IGESControl_Reader reader; IFSelect_ReturnStatus status = reader.ReadFile(path); if (status != IFSelect_RetDone) return nullptr; - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); reader.TransferRoots(range); if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return nullptr; } @@ -390,7 +425,7 @@ OCCTShapeRef OCCTImportIGESProgress(const char* path, TopoDS_Shape shape = reader.OneShape(); if (shape.IsNull()) return nullptr; return new OCCTShape(shape); - } catch (...) { return nullptr; } + } catch (...) { setCancelOut(outCancelled, indicator); return nullptr; } } OCCTShapeRef OCCTImportIGESRobustProgress(const char* path, @@ -399,6 +434,8 @@ OCCTShapeRef OCCTImportIGESRobustProgress(const char* path, clearCancelOut(outCancelled); if (!path) return nullptr; std::lock_guard igesLock(igesMutex()); + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { IGESControl_Reader reader; Interface_Static::SetIVal("read.precision.mode", 0); @@ -407,14 +444,16 @@ OCCTShapeRef OCCTImportIGESRobustProgress(const char* path, IFSelect_ReturnStatus status = reader.ReadFile(path); if (status != IFSelect_RetDone) return nullptr; - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); // Healing is half the work of a robust import, not a coda to it: measured at 38-50% // of transfer+heal across box/sphere/cylinder/torus compounds. Giving TransferRoots // the whole range therefore left ~40% of the import running where the caller's // range could never reach it, so shouldCancel() during healing was ignored and a // deadline could not bound the call (#300, same family as #286). Split it evenly. Message_ProgressScope scope(indicator->Start(), "Import", 2); - if (reader.TransferRoots(scope.Next()) == 0) return nullptr; + // A break during the transfer leaves zero roots transferred, which is how a cancellation + // that lands in this phase reaches the caller -- as cancelled, not as a failed import (#525). + if (reader.TransferRoots(scope.Next()) == 0) { setCancelOut(outCancelled, indicator); return nullptr; } if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return nullptr; } TopoDS_Shape shape = reader.OneShape(); @@ -427,7 +466,7 @@ OCCTShapeRef OCCTImportIGESRobustProgress(const char* path, if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return nullptr; } TopoDS_Shape fixed = fixer.Shape(); return new OCCTShape(fixed.IsNull() ? shape : fixed); - } catch (...) { return nullptr; } + } catch (...) { setCancelOut(outCancelled, indicator); return nullptr; } } OCCTDocumentRef OCCTDocumentLoadSTEPProgress(const char* path, @@ -438,6 +477,8 @@ OCCTDocumentRef OCCTDocumentLoadSTEPProgress(const char* path, // Serialize all DE reads: STEP/IGES share Interface_Static globals (#181-B, #359). std::lock_guard igesLock(igesMutex()); OCCTDocument* document = nullptr; + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { document = new OCCTDocument(); document->app->NewDocument("MDTV-XCAF", document->doc); @@ -453,7 +494,7 @@ OCCTDocumentRef OCCTDocumentLoadSTEPProgress(const char* path, IFSelect_ReturnStatus status = reader.ReadFile(path); if (status != IFSelect_RetDone) { delete document; return nullptr; } - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); bool ok = reader.Transfer(document->doc, range); if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); delete document; return nullptr; } @@ -463,7 +504,7 @@ OCCTDocumentRef OCCTDocumentLoadSTEPProgress(const char* path, document->colorTool = XCAFDoc_DocumentTool::ColorTool(document->doc->Main()); document->materialTool = XCAFDoc_DocumentTool::VisMaterialTool(document->doc->Main()); return document; - } catch (...) { delete document; return nullptr; } + } catch (...) { setCancelOut(outCancelled, indicator); delete document; return nullptr; } } // MARK: - Mesh + export progress (v0.169.0, issue #98 follow-up) @@ -475,8 +516,10 @@ OCCTShapeRef OCCTShapeIncrementalMeshProgress(OCCTShapeRef shape, bool* outCancelled) { clearCancelOut(outCancelled); if (!shape) return nullptr; + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); // The (shape, linDefl, isRelative, angDefl) ctor calls Perform() internally with a null // range, so it meshes uninterruptibly before any range we pass afterwards is ever polled // (and a following Perform(range) then meshes a second time). Only the parameters ctor @@ -492,25 +535,27 @@ OCCTShapeRef OCCTShapeIncrementalMeshProgress(OCCTShapeRef shape, // Return a new OCCTShape wrapping the same (now-meshed) TopoDS_Shape so callers // can chain. The original handle is also valid. return new OCCTShape(shape->shape); - } catch (...) { return nullptr; } + } catch (...) { setCancelOut(outCancelled, indicator); return nullptr; } } bool OCCTExportSTEPProgress(OCCTShapeRef shape, const char* path, const OCCTImportProgress* ctx, bool* outCancelled) { clearCancelOut(outCancelled); if (!shape || !path) return false; + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { // Serialize all DE writes: STEP/IGES share Interface_Static globals (#181-B). std::lock_guard deLock(igesMutex()); STEPControl_Writer writer; Interface_Static::SetCVal("write.step.schema", "AP214"); - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); IFSelect_ReturnStatus status = writer.Transfer(shape->shape, STEPControl_AsIs, true, range); if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return false; } if (status != IFSelect_RetDone) return false; return writer.Write(path) == IFSelect_RetDone; - } catch (...) { return false; } + } catch (...) { setCancelOut(outCancelled, indicator); return false; } } bool OCCTExportSTEPWithModeProgress(OCCTShapeRef shape, const char* path, int32_t modelType, @@ -519,17 +564,19 @@ bool OCCTExportSTEPWithModeProgress(OCCTShapeRef shape, const char* path, int32_ if (!shape || !path) return false; // Serialize all DE writes: STEP/IGES share Interface_Static globals (#181-B, #359). std::lock_guard deLock(igesMutex()); + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { STEPControl_Writer writer; Interface_Static::SetCVal("write.step.schema", "AP214"); - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); STEPControl_StepModelType mode = static_cast(modelType); IFSelect_ReturnStatus status = writer.Transfer(shape->shape, mode, true, range); if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return false; } if (status != IFSelect_RetDone) return false; return writer.Write(path) == IFSelect_RetDone; - } catch (...) { return false; } + } catch (...) { setCancelOut(outCancelled, indicator); return false; } } bool OCCTExportIGESProgress(OCCTShapeRef shape, const char* path, @@ -537,24 +584,29 @@ bool OCCTExportIGESProgress(OCCTShapeRef shape, const char* path, clearCancelOut(outCancelled); if (!shape || !path || shape->shape.IsNull()) return false; std::lock_guard igesLock(igesMutex()); + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { BRepCheck_Analyzer analyzer(shape->shape); if (!analyzer.IsValid()) return false; IGESControl_Writer writer("MM", 0); - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); - if (!writer.AddShape(shape->shape, range)) return false; + // AddShape reports failure for a transfer the break aborted, so ask before believing it (#525). + if (!writer.AddShape(shape->shape, range)) { setCancelOut(outCancelled, indicator); return false; } if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return false; } writer.ComputeModel(); return writer.Write(path); - } catch (...) { return false; } + } catch (...) { setCancelOut(outCancelled, indicator); return false; } } bool OCCTDocumentWriteSTEPProgress(OCCTDocumentRef doc, const char* path, const OCCTImportProgress* ctx, bool* outCancelled) { clearCancelOut(outCancelled); if (!doc || !path) return false; + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { // Serialize all DE writes: STEP/IGES share Interface_Static globals (#181-B). std::lock_guard deLock(igesMutex()); @@ -564,7 +616,7 @@ bool OCCTDocumentWriteSTEPProgress(OCCTDocumentRef doc, const char* path, writer.SetLayerMode(Standard_True); writer.SetPropsMode(Standard_True); writer.SetMaterialMode(Standard_True); - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); if (!writer.Transfer(doc->doc, STEPControl_AsIs, nullptr, range)) { if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return false; } @@ -573,7 +625,7 @@ bool OCCTDocumentWriteSTEPProgress(OCCTDocumentRef doc, const char* path, if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); return false; } IFSelect_ReturnStatus status = writer.Write(path); return status == IFSelect_RetDone; - } catch (...) { return false; } + } catch (...) { setCancelOut(outCancelled, indicator); return false; } } OCCTDocumentRef OCCTDocumentLoadSTEPWithModesProgress(const char* path, @@ -586,6 +638,8 @@ OCCTDocumentRef OCCTDocumentLoadSTEPWithModesProgress(const char* path, // Serialize all DE reads: STEP/IGES share Interface_Static globals (#181-B, #359). std::lock_guard igesLock(igesMutex()); OCCTDocument* document = nullptr; + // Declared outside the try so the catch below can still answer "was this cancelled?" (#525). + opencascade::handle indicator; try { document = new OCCTDocument(); document->app->NewDocument("MDTV-XCAF", document->doc); @@ -602,7 +656,7 @@ OCCTDocumentRef OCCTDocumentLoadSTEPWithModesProgress(const char* path, IFSelect_ReturnStatus status = reader.ReadFile(path); if (status != IFSelect_RetDone) { delete document; return nullptr; } - opencascade::handle indicator = new BridgeProgressIndicator(ctx); + indicator = new BridgeProgressIndicator(ctx); Message_ProgressRange range = indicator->Start(); bool ok = reader.Transfer(document->doc, range); if (indicator->UserBreak()) { setCancelOut(outCancelled, indicator); delete document; return nullptr; } @@ -612,7 +666,7 @@ OCCTDocumentRef OCCTDocumentLoadSTEPWithModesProgress(const char* path, document->colorTool = XCAFDoc_DocumentTool::ColorTool(document->doc->Main()); document->materialTool = XCAFDoc_DocumentTool::VisMaterialTool(document->doc->Main()); return document; - } catch (...) { delete document; return nullptr; } + } catch (...) { setCancelOut(outCancelled, indicator); delete document; return nullptr; } } // MARK: - Import diff --git a/Sources/OCCTSwift/ImportProgress.swift b/Sources/OCCTSwift/ImportProgress.swift index 364497bc..47924719 100644 --- a/Sources/OCCTSwift/ImportProgress.swift +++ b/Sources/OCCTSwift/ImportProgress.swift @@ -25,6 +25,35 @@ import OCCTBridge /// `shouldCancel()` is polled at OCCT's progress-checkpoint boundaries /// (typically once per transferred entity in STEP/IGES). Returning `true` aborts /// the in-flight import; the loader throws `ImportError.cancelled`. +/// +/// ## What a cancelled call reports +/// +/// One `true` is enough, and it is remembered: the answer is not re-asked into a different +/// outcome by the polls that follow, so a one-shot flag or an already-consumed +/// `Task.isCancelled` works as a canceller. +/// +/// A cancelled call always throws `ImportError.cancelled` (`ExportError.cancelled` for the +/// exporters), whichever phase the cancellation lands in — a break during a STEP transfer +/// leaves the reader reporting zero transferred roots, which the bridge used to pass on as +/// `ImportError.importFailed` for an import the caller had explicitly stopped (#525). +/// +/// ```swift +/// final class Cancel: ImportProgress, @unchecked Sendable { +/// private let flag = NSLock() +/// private var stop = false +/// func cancel() { flag.lock(); stop = true; flag.unlock() } +/// func progress(fraction: Double, step: String) {} +/// func shouldCancel() -> Bool { flag.lock(); defer { flag.unlock() }; return stop } +/// } +/// +/// let canceller = Cancel() +/// do { +/// let shape = try Shape.loadRobust(from: stepURL, progress: canceller) +/// print(shape.faceCount) +/// } catch ImportError.cancelled { +/// print("stopped") // never .importFailed, whichever phase was running +/// } +/// ``` public protocol ImportProgress: AnyObject, Sendable { /// Called as the importer advances. `fraction` is `0.0...1.0`. `step` is a /// human-readable name of the current sub-task (may be empty). @@ -32,7 +61,8 @@ public protocol ImportProgress: AnyObject, Sendable { /// Return `true` to cooperatively cancel the in-flight import. Polled at /// each progress checkpoint. The loader throws `ImportError.cancelled` on - /// the next boundary after this returns `true`. + /// the next boundary after this returns `true`, and later polls returning + /// `false` do not undo that. func shouldCancel() -> Bool } diff --git a/Sources/OCCTSwift/Shape.swift b/Sources/OCCTSwift/Shape.swift index 889ecbac..e5731b2c 100644 --- a/Sources/OCCTSwift/Shape.swift +++ b/Sources/OCCTSwift/Shape.swift @@ -1148,6 +1148,9 @@ public final class Shape: @unchecked Sendable { /// deadline in `shouldCancel()` therefore bounds the whole call, and cancelling mid-repair /// throws rather than returning the partially-repaired shape. /// + /// Cancelling *anywhere* throws ``ImportError/cancelled``, including during the transfer, which + /// used to report `ImportError.importFailed` instead (#525). + /// /// ```swift /// final class Deadline: ImportProgress, @unchecked Sendable { /// private let start = Date() diff --git a/Tests/OCCTIOTests/OCCTIOTests.swift b/Tests/OCCTIOTests/OCCTIOTests.swift index 2bba53f7..4d801312 100644 --- a/Tests/OCCTIOTests/OCCTIOTests.swift +++ b/Tests/OCCTIOTests/OCCTIOTests.swift @@ -2166,15 +2166,91 @@ struct STEPWriterOversizedNameTests { } } -/// `.serialized` because both cancellation tests calibrate a deadline against a baseline import -/// they time themselves. Run concurrently they compete for CPU, inflating each other's baseline -/// until `budget = 0.75 * full` overshoots the real import and the deadline never fires — the -/// import then completes and the test reports a shape where it wanted a cancellation. The budget -/// has to sit above the transfer (~55% of the import) and below 100%, so there is no margin to -/// widen; serialising is the fix. Observed: both pass alone, the IGES one fails beside the STEP one. +/// `.serialized` because each test measures a baseline import and then compares a cancelled one +/// against it. The comparison is a poll count, not a duration, so it no longer competes for CPU +/// the way the wall-clock deadlines these tests used to carry did (#525) — but the baseline import +/// itself is the most expensive thing in the file, and running the two side by side buys nothing. @Suite("v1.11.2 Robust import progress (issue #300)", .serialized) struct RobustImportProgressTests { + /// Cancels once the import reports itself past the halfway mark, which by the bridge's own + /// split — transfer 0...0.5, repair 0.5...1.0 — is inside the repair. + /// + /// Triggered on reported progress, not on the clock. These tests used to set a deadline at + /// `0.75 ×` a wall-clock measurement of a preceding uncancelled import, so machine load, not + /// the bridge, decided which phase the cancellation landed in: about 1 run in 9 landed in the + /// transfer instead (#525). + /// + /// Progress *names* cannot stand in for the phase, tempting as they look: both readers run a + /// `ShapeFix_Shape` of their own during the transfer, so `Fixing face` / `Fixing edge` / + /// `Update tolerances` are already being reported from fraction ~0.09, long before the + /// bridge's repair phase begins (measured, OCCT 8.0.0p1). The fraction is the only phase + /// signal a caller actually has. + final class RepairPhaseCanceller: ImportProgress, @unchecked Sendable { + private let lock = NSLock() + private var _polls = 0 + private var _cancel = false + private var _fractionAtCancel: Double? + + /// Progress polls seen — the work-count proxy the assertions compare against a baseline. + var polls: Int { lock.lock(); defer { lock.unlock() }; return _polls } + /// The fraction that first crossed into the repair half, or nil if none ever did. + var fractionAtCancel: Double? { lock.lock(); defer { lock.unlock() }; return _fractionAtCancel } + + func progress(fraction: Double, step: String) { + lock.lock() + if fraction >= 0.6, !_cancel { _cancel = true; _fractionAtCancel = fraction } + lock.unlock() + } + + func shouldCancel() -> Bool { + lock.lock(); defer { lock.unlock() } + _polls += 1 + return _cancel + } + } + + /// The uncancelled baseline: counts polls, and times how long the call kept running after its + /// last progress report. That trailing silence is what the #300 defect looked like from + /// outside — the transfer consumed the whole range, reported 1.0, and then the healing ran on + /// for another 40-50% of the call with nothing left to report and no way to be cancelled. + /// Measured at 1.3% (STEP) and 3.4% (IGES) of the call with the repair inside the range. + /// + /// A ratio taken *within one call* rather than a budget calibrated against a previous one: + /// a slow machine stretches both halves of it, which is what makes it stable where the + /// deadline it replaces was not. + final class BaselineProgress: ImportProgress, @unchecked Sendable { + private let lock = NSLock() + private var _polls = 0 + private var _lastEvent: Date? + private var _lastFraction = 0.0 + var polls: Int { lock.lock(); defer { lock.unlock() }; return _polls } + var lastEvent: Date? { lock.lock(); defer { lock.unlock() }; return _lastEvent } + var lastFraction: Double { lock.lock(); defer { lock.unlock() }; return _lastFraction } + func progress(fraction: Double, step: String) { + lock.lock(); _lastEvent = Date(); _lastFraction = fraction; lock.unlock() + } + func shouldCancel() -> Bool { lock.lock(); _polls += 1; lock.unlock(); return false } + } + + /// Shared assertions for a baseline (uncancelled) robust import: the whole call has to be + /// covered by the progress range, which is the #300 property itself. + static func expectRangeCoversWholeCall(_ baseline: BaselineProgress, + start: Date, end: Date, + label: String) { + #expect(baseline.polls > 0, "\(label): the progress range was never consumed") + #expect(baseline.lastFraction > 0.99, + "\(label): progress stopped at \(baseline.lastFraction), short of the end of the call") + guard let last = baseline.lastEvent else { return } + let total = end.timeIntervalSince(start) + let tail = end.timeIntervalSince(last) + #expect(tail / total < 0.25, """ + \(label): the call ran on for \(tail)s of a \(total)s import after its last progress \ + report — that silent tail is work outside the caller's progress range, which can be \ + neither observed nor cancelled (#300) + """) + } + /// Regression for #300: a deadline must interrupt the *healing* phase of a robust import, /// not merely the transfer that precedes it. /// @@ -2185,14 +2261,13 @@ struct RobustImportProgressTests { /// healing was ignored entirely, the heal ran to completion, and the import returned a /// *shape* rather than reporting cancellation. Same family as #286. /// - /// The deadline is deliberately set *past* the transfer so it lands inside healing, the part - /// that was out of reach. Wall-clock phase costs are identical before and after the fix, so - /// this discriminates properly: the old bridge returns a shape here, the fixed one throws. - /// A cancel triggered on reported `fraction` instead would be a false negative — under the - /// old bridge the transfer alone spanned 0...1, so any fraction-based trigger fired while - /// the transfer was still running and cancelled correctly even with the bug present. - /// - /// Self-calibrating against a baseline import so it does not encode machine speed. + /// Two halves, because the defect had two faces. That healing is inside the range at all is + /// checked on the uncancelled baseline, by the silence that would follow the last progress + /// report if it were not (see ``BaselineProgress``) — a fraction-triggered cancellation alone + /// could not catch it, since the old bridge let the transfer span 0...1 and any fraction + /// therefore fired while the transfer was still running and cancelled correctly even with the + /// bug present. That a cancellation in that half then *stops* the healing rather than letting + /// it run to completion is checked against the baseline's poll count. @Test("Shape.loadIGESRobust interrupts healing, not just the transfer (#300)") func igesRobustHealCancellation() throws { // Healing's share of the import is largest for many-faced solids; 400 boxes makes the @@ -2210,39 +2285,31 @@ struct RobustImportProgressTests { defer { try? FileManager.default.removeItem(at: url) } try subject.writeIGES(to: url) + // Baseline: an uncancelled import, both as the "is the range covering the whole call" + // check and as the yardstick for "the cancelled run stopped early". The yardstick is a + // count of work items, not a duration — identical on a loaded and an idle machine. + let baseline = BaselineProgress() let t0 = Date() - _ = try Shape.loadIGESRobust(fromPath: url.path) - let full = Date().timeIntervalSince(t0) + _ = try Shape.loadIGESRobust(fromPath: url.path, progress: baseline) + Self.expectRangeCoversWholeCall(baseline, start: t0, end: Date(), label: "loadIGESRobust") - // Too quick to time meaningfully; there is no interruption window to observe. The - // import measures ~0.47s here, so this leaves >2x margin: the discrimination itself - // is scale-free (the budget sits at 75% and the transfer ends near 55%, whatever the - // absolute cost), and the guard only needs to keep the arithmetic clear of timer noise. - guard full > 0.2 else { return } - - // Past the transfer (~55% of the import), so the deadline falls inside healing. - let budget = full * 0.75 - let deadline = MeshAndExportProgressTests.Deadline(budget: budget) - let t1 = Date() + let canceller = RepairPhaseCanceller() do { - _ = try Shape.loadIGESRobust(fromPath: url.path, progress: deadline) - // Distinguish "the bug is back" from "this run was simply faster than the baseline - // the budget was calibrated against". If the import finished before its own deadline - // came due, nothing was ever asked to stop and the run proves nothing either way. - // With the defect present the import takes ~full, so the deadline always comes due - // and this cannot swallow a real regression. - if Date().timeIntervalSince(t1) < budget { return } - Issue.record("loadIGESRobust returned a shape instead of cancelling — healing ran outside the caller's progress range (#300)") + _ = try Shape.loadIGESRobust(fromPath: url.path, progress: canceller) + let at = canceller.fractionAtCancel.map { "fraction \($0)" } ?? "no fraction >= 0.6 was ever reported" + Issue.record(""" + loadIGESRobust returned a shape instead of cancelling — a break requested at \ + \(at) did not stop the healing (#300) + """) return } catch ImportError.cancelled { - // Expected. + // Expected. Any other error is a cancellation reported through the wrong case (#525). } catch { Issue.record("Unexpected error: \(error)"); return } - let elapsed = Date().timeIntervalSince(t1) - #expect(deadline.polls > 0, "cancellation was never polled — the range was not consumed") - #expect(elapsed < full * 0.95, - "cancelled after \(elapsed)s against a \(full)s uncancelled import — healing ran to completion before the deadline could bite (#300)") + #expect(canceller.polls > 0, "cancellation was never polled — the range was not consumed") + #expect(canceller.polls < baseline.polls, + "cancelled after \(canceller.polls) polls against \(baseline.polls) uncancelled — healing ran to completion before the break could bite (#300)") } /// Regression for #300 (STEP side): `loadRobust`'s repair phase must honour the deadline too. @@ -2253,9 +2320,8 @@ struct RobustImportProgressTests { /// /// The fixture is a convex N-gon prism: one many-faced **solid**, so the import takes the robust /// path's SOLID branch (transfer, then heal — no sewing), where healing measures ~50% of the - /// work. That share is what lets a deadline land in the repair phase at all: on a *compound* - /// the same import spends only ~6% there, and a deadline would fall in the transfer instead — - /// which was already cancellable, so such a test would pass with the bug present. Convex also + /// work. That share is what makes an interrupted repair observable at all: on a *compound* the + /// same import spends only ~6% there, too thin to distinguish from the transfer. Convex also /// keeps clear of #263 (ShapeFix heap-corrupts healing a self-intersecting-wire prism). @Test("Shape.loadRobust interrupts repair, not just the transfer (#300)") func stepRobustRepairCancellation() throws { @@ -2274,38 +2340,35 @@ struct RobustImportProgressTests { defer { try? FileManager.default.removeItem(at: url) } try subject.writeSTEP(to: url) + // Baseline: an uncancelled import, both as the "is the range covering the whole call" + // check and as the yardstick for "the cancelled run stopped early". + let baselineProgress = BaselineProgress() let t0 = Date() - let baseline = try Shape.loadRobust(fromPath: url.path) - let full = Date().timeIntervalSince(t0) + let baseline = try Shape.loadRobust(fromPath: url.path, progress: baselineProgress) + Self.expectRangeCoversWholeCall(baselineProgress, start: t0, end: Date(), label: "loadRobust") // Guards the premise: a compound here would mean the sewing branch and a ~6% repair - // share, and the deadline below would land in the transfer instead of the repair. + // share, and the repair phase would be too thin to observe being interrupted. #expect(baseline.shapeType == .solid, - "fixture is no longer a solid — the deadline would land in the transfer, not the repair (#300)") - - // Too quick to time meaningfully; the import measures ~1.4s here. - guard full > 0.2 else { return } + "fixture is no longer a solid — the cancellation would land in the transfer, not the repair (#300)") - // Past the transfer (~55% of the import, parsing included), so the deadline falls inside repair. - let budget = full * 0.75 - let deadline = MeshAndExportProgressTests.Deadline(budget: budget) - let t1 = Date() + let canceller = RepairPhaseCanceller() do { - _ = try Shape.loadRobust(fromPath: url.path, progress: deadline) - // See the IGES case above: an import that beat its own deadline was never asked to - // stop, so it is inconclusive rather than a failure. - if Date().timeIntervalSince(t1) < budget { return } - Issue.record("loadRobust returned a shape instead of cancelling — repair ran outside the caller's progress range (#300)") + _ = try Shape.loadRobust(fromPath: url.path, progress: canceller) + let at = canceller.fractionAtCancel.map { "fraction \($0)" } ?? "no fraction >= 0.6 was ever reported" + Issue.record(""" + loadRobust returned a shape instead of cancelling — a break requested at \ + \(at) did not stop the repair (#300) + """) return } catch ImportError.cancelled { - // Expected. + // Expected. Any other error is a cancellation reported through the wrong case (#525). } catch { Issue.record("Unexpected error: \(error)"); return } - let elapsed = Date().timeIntervalSince(t1) - #expect(deadline.polls > 0, "cancellation was never polled — the range was not consumed") - #expect(elapsed < full * 0.95, - "cancelled after \(elapsed)s against a \(full)s uncancelled import — repair ran to completion before the deadline could bite (#300)") + #expect(canceller.polls > 0, "cancellation was never polled — the range was not consumed") + #expect(canceller.polls < baselineProgress.polls, + "cancelled after \(canceller.polls) polls against \(baselineProgress.polls) uncancelled — repair ran to completion before the break could bite (#300)") } /// `progress: nil` must still import normally — the default path now routes through the @@ -2326,6 +2389,148 @@ struct RobustImportProgressTests { } } +/// Regressions for #525: a cancelled import must report *cancellation*, whichever phase the +/// cancellation lands in and however many times the caller is willing to say so. +/// +/// The bridge set `*outCancelled` only at its own explicit `UserBreak()` checkpoints, so which +/// error a caller saw depended on where the break happened to fall. A break during the transfer +/// leaves `TransferRoots` reporting zero roots, and that path returned "failed" with the flag +/// still false — `ImportError.importFailed`, for an import the caller had explicitly cancelled. +/// It surfaced as a flaky test (#300's, whose deadline was a fraction of a wall-clock measurement +/// and so landed in the transfer about 1 run in 9), but it is reachable by any caller whose +/// deadline expires early. +/// +/// Separately, `UserBreak()` re-asked the caller at every checkpoint and took the latest answer, +/// so a caller that answers `true` **once** — a one-shot flag, an already-consumed +/// `Task.isCancelled` — had that answer overwritten: OCCT aborted the phase, the next poll said +/// "no break", and the half-repaired shape came back as a success. `ImportProgress.shouldCancel` +/// documents the opposite: one `true` stops the call. +@Suite("Cancellation is reported as cancellation (issue #525)", .serialized) +struct CancellationReportingTests { + + /// Cancels on the very first poll, before any phase has made progress. + final class ImmediateCanceller: ImportProgress, @unchecked Sendable { + private let lock = NSLock() + private var _polls = 0 + var polls: Int { lock.lock(); defer { lock.unlock() }; return _polls } + func progress(fraction: Double, step: String) {} + func shouldCancel() -> Bool { lock.lock(); _polls += 1; lock.unlock(); return true } + } + + /// Answers `true` exactly once — once the import is past halfway, so the single `true` lands + /// in the repair phase — then `false` forever after. + final class OneShotCanceller: ImportProgress, @unchecked Sendable { + private let lock = NSLock() + private var _fired = false + private var _pastHalfway = false + var fired: Bool { lock.lock(); defer { lock.unlock() }; return _fired } + func progress(fraction: Double, step: String) { + lock.lock() + if fraction >= 0.6 { _pastHalfway = true } + lock.unlock() + } + func shouldCancel() -> Bool { + lock.lock(); defer { lock.unlock() } + guard _pastHalfway, !_fired else { return false } + _fired = true + return true + } + } + + private func prismSTEP(named name: String) throws -> URL { + let sides = 1200 + let points = (0.. SIMD2 in + let a = 2 * Double.pi * Double(i) / Double(sides) + return SIMD2(1000 * cos(a), 1000 * sin(a)) + } + let profile = try #require(Wire.polygon(points)) + let subject = try #require(Shape.extrude(profile: profile, direction: SIMD3(0, 0, 1), length: 50)) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(name) + try subject.writeSTEP(to: url) + return url + } + + /// The #525 case itself: cancelling before the transfer completes threw `.importFailed`, + /// because zero transferred roots was read as a failed import rather than a stopped one. + @Test("Shape.loadRobust cancelled during the transfer throws .cancelled, not .importFailed (#525)") + func stepRobustTransferPhaseCancellationIsCancelled() throws { + let url = try prismSTEP(named: "occt525_transfer_cancel.step") + defer { try? FileManager.default.removeItem(at: url) } + + let canceller = ImmediateCanceller() + do { + _ = try Shape.loadRobust(fromPath: url.path, progress: canceller) + Issue.record("loadRobust returned a shape despite cancelling on the first poll") + } catch ImportError.cancelled { + #expect(canceller.polls > 0) + } catch { + Issue.record("cancellation reported as \(error) rather than ImportError.cancelled (#525)") + } + } + + /// The IGES sibling of the same bridge path — identical `TransferRoots(...) == 0` exit. + @Test("Shape.loadIGESRobust cancelled during the transfer throws .cancelled (#525)") + func igesRobustTransferPhaseCancellationIsCancelled() throws { + let boxes = (0..<50).compactMap { i in + Shape.box(width: 10, height: 10, depth: 10)?.translated(by: SIMD3(Double(i) * 30, 0, 0)) + } + let subject = try #require(Shape.compound(boxes)) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("occt525_transfer_cancel.igs") + defer { try? FileManager.default.removeItem(at: url) } + try subject.writeIGES(to: url) + + let canceller = ImmediateCanceller() + do { + _ = try Shape.loadIGESRobust(fromPath: url.path, progress: canceller) + Issue.record("loadIGESRobust returned a shape despite cancelling on the first poll") + } catch ImportError.cancelled { + #expect(canceller.polls > 0) + } catch { + Issue.record("cancellation reported as \(error) rather than ImportError.cancelled (#525)") + } + } + + /// The plain (non-robust) importer takes the same channel, so it gets the same guarantee. + @Test("Shape.loadSTEP cancelled on the first poll throws .cancelled (#525)") + func stepPlainCancellationIsCancelled() throws { + let url = try prismSTEP(named: "occt525_plain_cancel.step") + defer { try? FileManager.default.removeItem(at: url) } + + let canceller = ImmediateCanceller() + do { + _ = try Shape.loadSTEP(fromPath: url.path, progress: canceller) + Issue.record("loadSTEP returned a shape despite cancelling on the first poll") + } catch ImportError.cancelled { + #expect(canceller.polls > 0) + } catch { + Issue.record("cancellation reported as \(error) rather than ImportError.cancelled (#525)") + } + } + + /// One `true` has to be enough. The indicator used to re-ask at every checkpoint and believe + /// the last answer, so this caller's single `true` aborted the repair and was then forgotten: + /// the import returned the partially-repaired shape as a success. + @Test("A caller that cancels once is not re-asked into an uncancelled result (#525)") + func oneShotCancellationSticks() throws { + let url = try prismSTEP(named: "occt525_oneshot_cancel.step") + defer { try? FileManager.default.removeItem(at: url) } + + let canceller = OneShotCanceller() + do { + _ = try Shape.loadRobust(fromPath: url.path, progress: canceller) + Issue.record(""" + loadRobust returned a shape after the caller cancelled — a single shouldCancel() \ + true was overwritten by the polls after it (fired: \(canceller.fired)) (#525) + """) + } catch ImportError.cancelled { + #expect(canceller.fired, "the import stopped without the canceller ever firing") + } catch { + Issue.record("cancellation reported as \(error) rather than ImportError.cancelled (#525)") + } + } +} + /// Regressions for #302: the robust importers sewed, then kept only the **first** shell, silently /// discarding every body after it — 10 boxes in, 1 box out, no error and no diagnostic. /// diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 42ff8343..77fc8c7f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,51 @@ All notable changes to OCCTSwift. ## Release History +### Unreleased: fix, a cancelled import could report `.importFailed` instead of `.cancelled` (#525) + +> Version and date deliberately unset; whoever tags stamps them. + +`OCCTImportSTEPRobustProgress` set `*outCancelled` only at its own explicit `UserBreak()` +checkpoints, so which error a cancelled import reported depended on which phase the cancellation +happened to land in. A break during the transfer leaves `TransferRoots` reporting zero transferred +roots, and that exit returned "failed" with the flag still false: + +```swift +// Deadline expires while the transfer is still running +catch ImportError.importFailed("Failed to import: /tmp/part.step") // was: for a readable file +catch ImportError.cancelled // now +``` + +Found as a flake in #300's own regression test, which set its deadline at 0.75 × a wall-clock +measurement of a preceding uncancelled import: machine load, not the bridge, decided which phase +the deadline fell in, and about 1 run in 9 fell in the transfer. Any caller whose deadline expires +early reaches the same path. + +Every failure exit below the indicator's construction now reports cancellation if a break was +observed — the zero-roots exit, a null shape, a non-`Done` status, and the `catch (...)` handler +(which needed the indicator hoisted out of the `try`). Applied across all twelve `*Progress` entry +points in `OCCTBridge_IO.mm`, not only the two robust importers, since they share the shape. + +A second defect surfaced while probing the first: `BridgeProgressIndicator::UserBreak()` re-asked +the caller at every checkpoint and believed the latest answer, so a caller that answers `true` +**once** — a one-shot flag, an already-consumed `Task.isCancelled` — had that answer overwritten. +OCCT aborted the phase, the next poll said "no break", and the half-repaired shape came back as a +*success*. The break is now latched (`std::atomic`, since OCCT documents `UserBreak()` as +callable concurrently), which is what `ImportProgress.shouldCancel` always documented. + +Both `#300` regression tests were rewritten off the clock. That the repair phase lies inside the +caller's progress range is now checked by the silence that would follow the last progress report +if it did not: measured at 1.3% (STEP) and 3.4% (IGES) of the call with the fix, 35–40% with the +#300 defect reintroduced. That a cancellation there *stops* the repair is checked against the +uncancelled run's poll count — a count of work items, not a duration, and identical on a loaded and +an idle machine. Progress *names* cannot substitute for the phase, tempting as they look: both +readers run a `ShapeFix_Shape` of their own during the transfer, so `Fixing face` / `Fixing edge` / +`Update tolerances` are already being reported from fraction ~0.09. + +Bridge-only change: no OCCT kernel patch, no `OCCT.xcframework` rebuild; `OCCTBridge.xcframework` +needs one since `OCCTBridge_IO.mm` changed. The previously flaky suites ran 12/12 clean; each new +test was verified to fail against the defect it covers, re-injected one at a time. + ### Unreleased: fix, a refused `FillingSurface.add` still let `build()` return a face (#482) > Version and date deliberately unset; whoever tags stamps them. diff --git a/docs/reference/Concurrency.md b/docs/reference/Concurrency.md index 7549a766..d7e70782 100644 --- a/docs/reference/Concurrency.md +++ b/docs/reference/Concurrency.md @@ -159,3 +159,5 @@ A default no-op implementation returning `false` is provided via a protocol exte } ``` - **Note:** `shouldCancel()` is polled once per transferred entity in STEP/IGES — typically many times per second for large files. Keep the implementation cheap (e.g. read an atomic flag, not a lock). +- **One `true` is enough.** The bridge latches the first `true` it sees, so a one-shot flag or an already-consumed `Task.isCancelled` is a valid canceller: the polls that follow cannot re-answer the call into a successful result. Before [#525](https://github.com/SecondMouseAU/OCCTSwift/issues/525) they could, and a caller that cancelled once got the partially-repaired shape back as a success. +- **A cancelled call always throws `.cancelled`.** Which phase the cancellation lands in no longer decides which error you see. A break during a transfer leaves OCCT reporting zero transferred roots, which the bridge used to pass on as `ImportError.importFailed` — so an early deadline reported "failed to import" for a file that was perfectly readable (#525).