Skip to content

fix(#913): guard CreateSmoothed's fixed-stride shapes array against a mismatched section edge count - #915

Merged
gsdali merged 5 commits into
mainfrom
fix/913-thrusections-createsmoothed-section-edge-count-guard
Aug 16, 2026
Merged

fix(#913): guard CreateSmoothed's fixed-stride shapes array against a mismatched section edge count#915
gsdali merged 5 commits into
mainfrom
fix/913-thrusections-createsmoothed-section-edge-count-guard

Conversation

@gsdali

@gsdali gsdali commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

What & why

ThruSectionsBuilder, given checkCompatibility(false) and 3+ sections where a later section has
a different edge count than the first, either SIGSEGVs/SIGBUSes (more edges than section 1) or
silently succeeds with wrong geometry (fewer edges) instead of failing cleanly. Root cause is in
BRepOffsetAPI_ThruSections::CreateSmoothed(): it derives the edge count it assumes every section
has from section 1 alone, allocates a fixed-size array on that assumption, then fills it by walking
each section's actual edges with no bounds check. Found incidentally while hunting for a #910
failure trigger; filed separately as #913 per this project's scope-boundary policy.

Revised after review (see the PR's own comment thread — 12 findings, all addressed or
explicitly documented): the guard is symmetric by construction (an inequality test, not a "too
many" test), so it always rejected the fewer-edges direction too — the first version of this PR's
own SemVer note and patch description just undersold that half. The punctual-section exemption now
also verifies a section actually has an edge before exempting it (hardening, not a proven-live
fix — see notes below), and its predicate is hoisted into a lambda shared with the pre-existing
fill loop instead of duplicated.

Closes #913

CHANGELOG entry

ThruSectionsBuilder no longer returns wrong or crashing results for a mismatched section edge count under checkCompatibility(false) (#913)

BRepOffsetAPI_ThruSections::CreateSmoothed() derived the edge count it assumes every section has
from section 1 alone, and filled a fixed-size array on that assumption with no bounds check. With
checkCompatibility(false), nothing reconciles differing section edge counts first (the default,
checkCompatibility(true), does this via BRepFill_CompatibleWires). A later section with more
edges than section 1 overran the array — heap corruption, observed as a SIGSEGV/SIGBUS once at
least 3 sections are involved (2 sections always take a different code path that doesn't share this
allocation shape). A section with fewer edges didn't crash, but silently misaligned per-section
strides to the wrong geometry, reporting build() == true for an invalid result. Both directions
are the same contract violation and are now rejected the same way. Fixed upstream:
OCCT#1466, carried as
Scripts/patches/0027-* and verified against a full local kernel rebuild (all 17 carried patches,
full swift test passing).

SemVer impact

PATCH. A caller using checkCompatibility(false) with 3+ sections of differing edge counts now
gets a clean build() == false instead of either a process crash (more edges) or a silently wrong
successful result (fewer edges). No signature change, no migration — this corrects both directions
of the same contract violation into the "graceful failure" contract every other
ThruSectionsBuilder error path already has.

Checklist

  • New or changed behavior is covered by a unit test in the same PR (not just manual
    verification): mismatchedSectionEdgeCountWithoutCheckFailsCleanly() (the original
    more-edges crash, gated — see below) and punctualApexWithMatchingSectionsStillSucceedsUnderCreateSmoothed()
    (the punctual-exemption regression guard, PR review finding 5 — a legitimate cone-apex loft
    under CreateSmoothed() must keep succeeding) in
    Tests/OCCTStressTests/StressBuilderLifecycleTests.swift. The fewer-edges direction is
    proven upstream instead, in the same GTest file this patch already touches (see below) —
    not duplicated at the Swift level.
  • Every new test and every new --self-test case was run once with its subject broken, and the
    failure is reported here: ran mismatchedSectionEdgeCountWithoutCheckFailsCleanly filtered
    against the currently pinned (unfixed) remote kernel first — it crashed the whole
    swift test process with exited with unexpected signal code 11 (SIGSEGV), matching the
    standalone C++ repro and PR fix(#913): guard CreateSmoothed's fixed-stride shapes array against a mismatched section edge count #915's own original CI run exactly. Then rebuilt the kernel
    locally with the patch (Scripts/build-occt.sh, all 17 carried patches applying cleanly) and
    re-ran: passes, along with the full suite.
  • The CHANGELOG entry above is complete, and docs/CHANGELOG.md is not in this diff.
  • The SemVer impact above is stated, and docs/SEMVER.md is not in this diff.

Notes for the reviewer

Root-cause method: a standalone C++ reproducer (no Swift/bridge involved) with a custom
SIGSEGV/SIGBUS handler (backtrace_symbols_fd, since lldb/core dumps are blocked in this sandboxed
dev environment) isolated the crash to CreateSmoothed(). Instrumenting the fill loop directly
confirmed the write index reaches one past the array's upper bound before the corrupting
assignment. The original "reused builder" framing in #913's own report turned out to be a symptom,
not the cause: a single Build() call with all mismatched sections present from the start doesn't
reliably crash either, even though the identical out-of-bounds write still occurs — what varies is
process/allocator state at the time of the overrun. Full mechanism, the override-link validation
(with and without No_Exception/NDEBUG, matching a Release build), and the fewer-edges/zero-edge
findings from review live in Scripts/patches/README.md's 0027 entry, and in a committed
reproducer at Scripts/repro/913-thrusections-createsmoothed-section-edge-count-guard/
(README + standalone .mm + stock.txt/patched.txt transcripts, matching #905's own convention
— missing from this PR's first version, per review finding 7).

Test gating (review finding 1, the load-bearing one — confirmed live via this PR's own first CI
run, which crashed swift build + test (macOS) with exactly the predicted SIGSEGV)
:
mismatchedSectionEdgeCountWithoutCheckFailsCleanly is now @Test(.enabled(if: ProcessInfo.processInfo.environment["OCCTSWIFT_LOCAL"] == "1")) — it needs patch 0027, which isn't
in Package.swift's pinned kernel asset yet, and SwiftPM runs every test target in one process, so
an unguarded crash there would have aborted the whole suite for every future PR until the pin
moves, matching this project's own #585 failure shape and #905/PR #909's precedent (which
avoided adding a Swift test for the identical reason). kernel-integration.yml sets
OCCTSWIFT_LOCAL=1 when it builds Scripts/patches/ from source, so the test runs (and is
verified) there, not against the pinned kernel.

This PR does a full local kernel rebuild for verification (Scripts/build-occt.sh, ~1hr,
Libraries/OCCT.xcframework is gitignored so it isn't in this diff) rather than deferring to
override-link evidence alone, unlike #905/#909's 0026. Package.swift's url:/checksum: pin is
not touched here — per the release process, that only moves at a release commit, and patch 0027
joins 0026 as carried-but-not-yet-in-the-pinned-asset until then.

Upstream: OCCT#1466 (open, mergeable, 4 GTests
total — 2 original plus a fewer-edges regression test added after review, both crash/wrong-result-
and-fix proven the same way as this PR's own Swift tests; a zero-edge GTest was written, found not
to distinguish pre/post-fix behavior on the pristine kernel, and removed rather than kept as
unproven coverage — see the PR #915 review thread, finding 4, for the full attempt).

… mismatched section edge count

BRepOffsetAPI_ThruSections::CreateSmoothed() derives the edge count it
assumes every section has from section 1 alone, allocates a shapes array
sized on that assumption, then fills it walking each section's actual
edges with no bounds check. With checkCompatibility(false), nothing
reconciles differing section edge counts first, so a later section with
more edges than section 1 overruns the array -- heap corruption, observed
as SIGSEGV/SIGBUS once at least 3 sections are involved. Found while
hunting for a failure trigger during #910's review, filed separately.

Isolated with a standalone C++ repro (no Swift/bridge) and a custom
SIGSEGV/SIGBUS handler, root-caused by instrumenting the fill loop
directly (write index reaches one past the array bound before the
crashing archive's own write). Confirmed the "reused builder" framing
from the original report is a symptom, not the cause: a single Build()
call with all mismatched sections from the start doesn't reliably crash
either, even though the same out-of-bounds write occurs -- what varies is
process/allocator state at the time of the overrun.

Carried as Scripts/patches/0027-*, filed upstream as
Open-Cascade-SAS/OCCT#1466 (open, mergeable, 2
new GTests, both crash- and regression-proven). Verified against a full
local rebuild (Scripts/build-occt.sh, all 17 patches including this one)
rather than override-link alone: full swift test, 5533/5533 passing.

New test mismatchedSectionEdgeCountWithoutCheckFailsCleanly()
(StressBuilderLifecycleTests.swift): proved it crashes the test process
with signal 11 against the currently pinned (unfixed) kernel, then passes
cleanly against the rebuilt kernel.

Closes #913

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gsdali added a commit that referenced this pull request Aug 15, 2026
Finding 1 (critical, verified empirically before fixing): IsDone() is
not a reliable "did the last build succeed" signal on a reused builder.
BRepOffsetAPI_ThruSections::Build()'s two punctual-section validation
loops (a wholly-degenerate MIDDLE section, reached via the public
addVertex()) return WrongUsage without ever calling OCCT's NotDone() --
so on a builder that already built successfully once, IsDone() stays
stale-true through a failed rebuild. The original #910 fix's IsDone()
guard does not catch this: build() incorrectly returns true, and both
shape and generatedFace(from:) still serve the prior build's stale data.

Finding 2: fix by recording the real outcome once, in OCCTThruSectionsBuild
(the one place that calls Build()), via a new `built` field on the
wrapper struct -- gated on IsDone() && GetStatus() ==
BRepFill_ThruSectionErrorStatus_Done, matching OCCT's own documented
GetStatus() accessor. OCCTThruSectionsShape and OCCTThruSectionsGeneratedFace
both gate on this bridge-side flag instead of re-deriving from OCCT
state per call, closing the gap for all three ways OCCTThruSectionsBuild
could previously under/over-report (sectionCount < 2, catch(...), and
this WrongUsage path).

Findings 3/7/9: existing test rewritten to `throws` + `try #require(...)`
so a setup failure fails loudly instead of silently skipping; added the
sibling `loft.shape == nil` assertion; dropped the no-op
checkCompatibility(true) call (already OCCT's own default).

Finding 4: added a ruled-path variant (isRuled: true) -- CreateRuled()
binds myEdgeFace via a different mechanism (BRepFill_Generator) than
CreateSmoothed(), and had no coverage.

Finding 1 also gets its own dedicated regression test
(generatedFaceNilAfterWrongUsageOnReusedBuilder), a distinct failure
mechanism from the existing open/closed-mismatch test. Reverted the
bridge fix to the pre-review IsDone()-only guard and confirmed this new
test fails with exactly the three symptoms the review predicted (build()
== true, shape != nil, generatedFace != nil); restored the fix and
confirmed all 8 tests in the suite pass, plus the full OCCTModelingTests
(645) and OCCTStressTests (363) targets.

Finding 5: noted, not changed. The positive assertion's edge identity
depends on BRepFill_CompatibleWires leaving already-matching-topology
input edges untouched -- true today, not a guarantee the API makes.
Documented the dependency inline rather than removing it: the
alternative (deriving the edge from loft.shape) tests different,
untested key-derivation semantics of GeneratedFace() and isn't
obviously more correct, and the negative assertion this test exists to
protect does not depend on this identity holding across OCCT versions
because it deals in nil either way.

Finding 6: updated ThruSectionsBuilder.swift's `///` doc comments for
both generatedFace(from:) and shape, and docs/reference/Document-Completions.md,
to state the new "nil if the last build did not succeed" contract.

Finding 8: the deferred SIGSEGV this PR's own body flagged
(checkCompatibility(false) + mismatched section edge counts on a reused
builder) has since been root-caused, fixed, and filed separately --
OCCTSwift#913, OCCT#1466, OCCTSwift PR #915 -- de-risking the concern
this finding raised about walking toward an uncharacterized crash.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@secondmouseAU-bot secondmouseAU-bot left a comment

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.

xhigh-effort review of 2ff177f. The --comment flag was dropped by the review tooling again, so these are posted directly. Claims below were cross-checked against the OCCT V8_0_1 source for BRepOffsetAPI_ThruSections.cxx, this repo's ci.yml/kernel-integration.yml, Package.swift, CLAUDE.md, and PR #909 (patch 0026) as the convention baseline.

The root-cause analysis is solid and the fix is in the right place mechanically — nbEdges genuinely does come from section 1 alone, and the fill loop genuinely has no bounds check. Twelve findings, most severe first.

1. The new test SIGSEGVs against the pinned kernel and takes the whole suite with it (Tests/OCCTStressTests/StressBuilderLifecycleTests.swift:439)

ci.yml's build-and-test runs plain swift test against Package.swift's pinned v2.0.0 asset (V8_0_1 + patches 0010-0025). Patch 0027 is not in it — the PR body says so explicitly. SwiftPM runs all test targets in one xctest process, so this test's out-of-bounds write aborts the entire run with exited with unexpected signal code 11, destroying every other test's result, not just its own.

This is not hypothetical: swift build + test (macOS) is already red on this PR (job 95007017539, failed at 11:41:58). Once merged, every subsequent PR's macOS job crashes with zero test results until the pin moves. ci.yml's own comment anticipates only "that patch's own regression tests fail here", not a signal kill. #909 (patch 0026, merged yesterday) deliberately added no Swift test and left the job green.

Gate it — a .disabled(...) trait or an OCCTSWIFT_LOCAL-conditional — until the pin moves, or publish the next v2.0.0-kernel.N pre-release and bump url:/checksum: as ci.yml prescribes.

2. Stale myEdgeFace remains observable after the new clean failure (patch line 81)

Build() clears myStatus/myBFGenerator/myNbEdgesInSection but never myEdgeFace, and on main today GeneratedFace() has no IsDone() gate at all — ThruSectionsBuilder.generatedFace(from:) exposes it directly. So: loft 2 circles, build() == true, add the mismatched third section, build() == false, and generatedFace(from: edgeOfCircle1) still returns the first build's face. This is the hazard 0026's own review demanded be closed in this same file.

Refinement worth recording: PR #912 is adding exactly that IsDone() guard, and for this patch's new path the guard is sufficient — CreateSmoothed() is called inside Build()'s try (.cxx:522), so the ProfilesInconsistent return falls through to the terminal if (myStatus != Done) { NotDone(); return; } at .cxx:531-535, and IsDone() correctly goes false. That is not true of the two WrongUsage early-returns at .cxx:356/:373, which skip NotDone() entirely (flagged on #912). So this finding is real on main, closed by #912 for this path specifically, and #912 still needs its own fix for the other paths.

3. The guard also rejects sections with fewer edges — a success→failure change, not just crash→failure (patch line 78)

aSectEdges != nbEdges is symmetric. With checkCompatibility(false) and 3 sections of 2, 1 and 3 edges: nbEdges = 2, shapes is sized 3*2 = 6, and the fill loop writes exactly 2+1+3 = 6 entries. No overrun, no nulls — Build() succeeds today (with silently misaligned per-section strides). After the patch it returns ProfilesInconsistent.

The patch header's validation covers three scenarios, all matching-count or checked; none is a fewer/mixed-count input. The SemVer statement ("a clean build() == false instead of a process crash") is wrong for this half of the newly-rejected surface — there it is "instead of a successful build".

4. The punctual exemption skips exactly the section whose branch reads Current() unguarded (patch line 69)

w1Point starts true and is only &&-ed inside the explorer loop, so a first section that is a wire with zero explorable edges is classified punctual. The guard continues past it; the fill loop (.cxx:768-776) then does anExp.Init(wire) and shapes(nb) = anExp.Current() nbEdges times with More() == false. BRepTools_WireExplorer::Current() returns myEdge unguarded (verified in V8_0_1 — no Standard_NoSuchObject raise), i.e. a null TopoDS_Edge; TotalSurf() then calls BRep_Tool::Curve on it and dereferences a null TShape. SIGSEGV, uncatchable, in the function this patch claims to make safe. Not reachable through the bridge today — but the guard is the thing deciding to let it through.

5. Only the crash direction is covered; the over-rejection direction has no test anywhere (:439)

The w1Point/w2Point exemption is the only thing keeping a cone-apex loft (addVertex, public API) working under CreateSmoothed. The repo's sole addVertex loft test is singleVertexBuildReturnsFalse (one vertex, fails by design); nothing lofts 3+ sections with a punctual end. The guard also runs on the myWCheck == true default path where the overrun is unreachable, and the only 3+-section loft test in the tree (Issue490BuilderContinuityTests) uses three identical-edge-count circles. If the exemption or the BRepFill_CompatibleWires assumption is wrong, neither ci.yml nor kernel-integration.yml catches it — that evidence exists only as upstream GTests this repo never runs.

6. No Known OCCT Bugs entry for #913 / 0027 (CLAUDE.md:309)

Confirmed: this PR changes exactly 3 files and CLAUDE.md is not one of them. The list carries a bullet for every other carried kernel defect (0011/#341, 0012/#344, 0014/#349, 0015/#353, 0016/#374, 0023/#643, 0026/#905), including ones not in the pinned asset, and #909 modified CLAUDE.md alongside its patch. The next reader auditing carried patches from CLAUDE.md sees 0026 as newest and misses 0027.

7. No committed reproducer (Scripts/patches/README.md:768)

Confirmed absent from the diff. CLAUDE.md requires "put the probe under Scripts/repro/<issue>/ rather than /tmp, so the evidence survives the session that produced it", and #909 shipped Scripts/repro/905-thrusections-capping-guard/ with README, .mm and stock.txt/patched.txt. The PR describes a standalone C++ reproducer with a backtrace_symbols_fd handler and an instrumented fill loop proving the index reaches one past shapes.Upper() — none of it is committed, so the 0027 entry's allocator-state claims are unreproducible by anyone else.

8. Three fixture bails make a crash-regression test green while asserting nothing (:440)

If Wire.circle, Wire.polygon3D or Shape.fromWire returns nil, mismatchedSectionEdgeCountWithoutCheckFailsCleanly returns early and passes. The second bail sits after the setup assertion, so a nil triangle skips the only assertion the test exists for. The regression here is a heap-corrupting out-of-bounds write — the one class of defect where a silently-skipped test is most expensive. try #require(...).

9. The guard duplicates the fill loop's punctual predicate instead of anchoring to it (patch line 69)

(iSect == 1 && w1Point) || (iSect == nbSects && w2Point) is a verbatim copy of (i == 1 && w1Point) || (i == nbSects && w2Point) fifteen lines below (.cxx:768). Any future change to which sections take the punctual branch updates one copy, and the guard then either exempts a section the loop walks (overrun returns) or rejects one it treats as punctual (false failure) — no compiler or test signal either way. Hoist to a shared isPunctualSection(iSect) helper.

10. The check re-walks work Build() already did, one level too low (patch line 67)

.cxx:471-508 (the else // no check branch) already iterates every edge of every section and records myNbEdgesInSection = max(inde). The guard walks them all again inside CreateSmoothed. Build() is where the counts already exist and where the myWCheck distinction that creates the hazard is expressed. Placing it in CreateSmoothed also scopes it by accident: CreateRuled() is left uncovered (safe today only because it walks both explorers pairwise), so the invariant holds in one of two sibling paths.

11. The exemption comment's "(zero) edge count" is factually wrong (patch line 71)

AddVertex() (.cxx:311-328) builds a TopoDS_Wire containing one degenerate TopoDS_Edge — I verified this directly in the V8_0_1 source. CreateSmoothed's punctual branch depends on anExp.Current() being a valid edge for that wire. A reader who trusts "zero" concludes the exemption is unnecessary (a zero count would be caught by != nbEdges anyway) and may delete it, turning every cone-apex loft into ProfilesInconsistent. The real reason is that the punctual branch consumes exactly nbEdges slots regardless of the section's own count — say that. The same wrong claim is repeated in the README entry.

12. SIGBUS vs SIGSEGV: the two records contradict each other (README.md:810)

The README says the unpatched GTest "crashes (SIGBUS) exactly as the issue describes"; the patch header says "SIGSEGVs on the stock library" and the PR checklist says "signal code 11 (SIGSEGV), matching the standalone C++ repro exactly". Both texts are in this diff. Signals 10 and 11 are different faults, and this defect's whole writeup turns on allocator/process state at the time of the overrun — exactly the detail a future reader uses to decide whether a new crash is this defect recurring. Reconcile, or state that both were observed and under which build.


Bottom line: the patch is correct for the case it targets, but finding 1 blocks merge on its own (it turns the macOS CI job into a signal kill for every later PR), and finding 3 means the behaviour change is wider than the SemVer note claims.

@secondmouseAU-bot secondmouseAU-bot left a comment

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.

xhigh-effort review of 2ff177f — round 2, gaps only.

An independent xhigh pass over the same commit reproduced 11 of the 12 findings already posted in round 1, including all the severe ones. Those are not restated here; round 1 stands as written.

Independently confirmed against the V8_0_1 source while re-deriving them, in case it is useful for the fix:

  • Round-1 #4 (punctual exemption / zero-edge wire). Verified end to end: BRepTools_WireExplorer::Current() in V8_0_1 is a bare return myEdge; with no Standard_NoSuchObject_Raise_if, so an exhausted explorer hands back a null TopoDS_Edge silently. TotalSurf's w1Point branch then does TopoDS_Edge edge = TopoDS::Edge(shapes(1));TopoDS::Edge calls ShapeType() on a null TShape. Uncatchable SIGSEGV, same function.
  • Round-1 #11 ("(zero) edge count"). Confirmed from two directions: AddVertex() builds DegWire with exactly one DegEdge (BB.MakeWire(DegWire); BB.Add(DegWire, DegEdge);), and BRepTools_WireExplorer does not skip degenerate edges — it has a SelectDegenerated() helper that actively prefers them. The count is 1, not 0, in both the patch comment and the README entry.
  • Round-1 #3 (fewer-edges direction). Agreed and worth restating precisely, because it is the finding most likely to be waved off: aSectEdges != nbEdges is symmetric, and any mismatch whose sum still equals nbSects * nbEdges (2/1/3 against nbEdges = 2) writes exactly 6 entries into a 6-slot array today — no overrun, no nulls, Build() returns true. That input goes from success to ProfilesInconsistent, which the SemVer note does not cover.

The four inline comments below are the findings round 1 did not raise. None is a crash; two are process/records defects that specifically compound round-1 #1, and two are test-shape defects.

# Where What
1 Scripts/patches/README.md:823 0027's entry omits the "carried on disk only / watch at the next re-pin" note that 0022-0026 all carry — the one sentence that explains why CI is red
2 Scripts/patches/README.md:768 CLAUDE.md's patch census now reads "fifteen" against 17 on disk, the exact drift that paragraph exists to catch (#585's shape)
3 StressBuilderLifecycleTests.swift:439 A .cxx-only patch's regression test placed in a lifecycle suite, so Package.swift's per-patch "which suite proves this reached the binary" audit has nothing to name at the re-pin
4 StressBuilderLifecycleTests.swift:454 The test asserts only the boolean, never loft.shape == nil — the one scenario in the suite where a stale myShape from the prior successful build() exists to leak

Findings 1 and 2 are the same root problem seen from two files: nothing in this diff records that 0027 is not in the pinned asset. Round-1 #1 is the runtime consequence of that fact; these two are the fact itself going unrecorded in the two places the project has designated for it. Fixing round-1 #1 by gating the test does not fix these — the records stay wrong either way, and they are what the next re-pin reads from.

Nothing new on the C++ guard itself beyond round 1: re-derived against V8_0_1, it is self-consistent with the fill loop it protects (both use BRepTools_WireExplorer, and the exemption predicate matches the loop's punctual branch exactly), and the myStatus/return correctly reaches NotDone() via Build()'s terminal status check. Round-1 #4 remains the one real hole in it.

Comment thread Scripts/patches/README.md

Filed upstream as [OCCT#1466](https://github.com/Open-Cascade-SAS/OCCT/pull/1466).

**Retire** once the bundled OCCT includes this fix.

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.

The 0027 entry drops the "carried on disk only / watch at the next re-pin" note every recent sibling carries — and it is more load-bearing here than for any of them.

The two context lines immediately above this added block say it for 0026:

0026 is carried on disk only — the first patch since 0022-0025 themselves were folded into that release to sit outside the pin. Watch for it at the next kernel re-pin.

0022-0025 got the same treatment ("Watch for it at the next re-pin, same as 0022-0024"). The 0027 entry ends at **Retire** once the bundled OCCT includes this fix. and never states that the pinned v2.0.0 asset does not contain it.

That omission matters more for 0027 than it did for 0026, because 0027 is the first carried-but-unpinned patch with a Swift test that depends on it (round-1 finding 1). The one sentence that tells a future reader "this is not in the pinned asset yet" is exactly the sentence that explains why swift build + test (macOS) is red, and it is the sentence that is missing.

Suggested change
**Retire** once the bundled OCCT includes this fix.
**Retire** once the bundled OCCT includes this fix. Until then, `0027` — like `0026` — is carried
on disk only and is **not** in the pinned `v2.0.0` asset, so `ci.yml`'s `build-and-test` exercises
the unpatched kernel. Watch for it at the next kernel re-pin.

Comment thread Scripts/patches/README.md
`0026` is carried on disk only — the first patch since `0022`-`0025` themselves were folded into
that release to sit outside the pin. Watch for it at the next kernel re-pin.

## 0027-BRepOffsetAPI_ThruSections-CreateSmoothed-section-edge-count-guard-913.patch

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.

Adding 0027 puts CLAUDE.md's patch census two behind, and CLAUDE.md names that exact drift as the #585 failure shape.

CLAUDE.md's Project Summary states the pin as:

Package.swift pins the v2.0.0-kernel.3 pre-release, which is that same V8_0_1 plus the fifteen carried patches 0010-0012 and 0014-0025.

and then makes checking it a standing instruction:

Check the count against Scripts/patches/ before trusting it. … Any patch present in Scripts/patches/ but absent from the pinned asset is exercised by no CI job at all … That is #585's failure shape, so it is worth ten seconds: ls Scripts/patches/*.patch | wc -l against the number in this paragraph. If they differ, the difference is the untested set …

After this PR that count is 17, against a paragraph that says fifteen and enumerates 0010-0025. 0026 already opened the gap by one; 0027 widens it to two, and neither is named anywhere as untested-against-the-pin. The reader who follows CLAUDE.md's own ten-second check now gets "17 vs 15, so two are untested" with no pointer to which two — which is the state the paragraph was written to prevent.

Cheapest fix that keeps the invariant true: extend that sentence to "… plus the fifteen carried patches 0010-0012 and 0014-0025; 0026 and 0027 are carried on disk and are not in the pinned asset." One line, and the census stays self-checking.

(Distinct from round-1 finding 6, which is about the Known OCCT Bugs list further down the same file.)

// (reached only at 3+ sections — 2 sections always take the CreateRuled() path instead) walked
// a fixed-stride array sized from section 1 alone with no bounds check, overrunning it and
// SIGSEGVing for a later section with more edges than the first. Must fail cleanly instead.
@Test func mismatchedSectionEdgeCountWithoutCheckFailsCleanly() {

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.

Wrong home for a kernel-patch regression test: it is invisible to the audit that will need it at the next re-pin.

This lands in Tests/OCCTStressTests/StressBuilderLifecycleTests.swift, whose file header declares its scope as:

// Category 6: Builder lifecycle patterns for all 11 builders + 3 fixers.
// Tests: build empty, normal cycle, reset, destroy without build, invalid input, double build.

This is not a lifecycle test — it is 0027's regression test, and the repo has an established, load-bearing convention for those. Package.swift's per-patch census is explicit about what those suites are for:

Five (0017, 0019, 0020, 0022, 0025) are .cxx-only and carry their own Swift regression suites (Issue484*, Issue522*, Issue532*, Issue568*, and the #597 case in OCCTSurfaceTests). ci.yml's build-and-test resolves this asset, not a local build, so a green run is behavioural proof those five reached the binary.

0027 is .cxx-only too, so it lands in exactly that bucket — and it is the only mechanism that will prove 0027 reached the rebuilt asset at the next re-pin. Buried in a lifecycle suite it can't play that role: swift test --filter Issue913 finds nothing, and whoever extends the census after the re-pin has no Issue913* suite to name.

Two things to change:

  1. Name it as a regression suiteIssue913ThruSectionsSectionEdgeCountTests (or similar), matching Issue484*/Issue522*/Issue532*/Issue568*.
  2. Put it in the domain target that matches, per CLAUDE.md Test Layout ("Add a new suite to the domain target that best matches it"). ThruSectionsExtensionsTests — including the existing checkCompatibility test — already lives in Tests/OCCTModelingTests/OCCTModelingTests.swift. That is where a checkCompatibility(false) correctness regression belongs, not in OCCTStressTests.

Doing this also gives round-1 finding 1's .disabled(...) gate a natural place to sit at suite level, and gives the re-pin commit one named thing to re-enable rather than a @Test buried mid-file.

SIMD3(2, 0, 20), SIMD3(-1, 1.7320508, 20), SIMD3(-1, -1.7320508, 20)
], closed: true), let triangleShape = Shape.fromWire(triangle) else { return }
loft.addWire(triangleShape)
#expect(!loft.build())

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.

#expect(!loft.build()) is the whole assertion; it never pins the "graceful failure" contract the PR body claims.

The PR describes the outcome as

the same "graceful failure" contract every other ThruSectionsBuilder error path already has

but the only thing asserted is the boolean. The sibling test 25 lines up in this same suite already does more:

@Test func buildEmpty() {
    let loft = ThruSectionsBuilder(isSolid: true, isRuled: false)
    let ok = loft.build()
    #expect(!ok)
    #expect(loft.shape == nil)     // ← the contract, not just the return value
}

That second assertion matters far more here than it does in buildEmpty, because this is the one scenario in the suite where a stale result exists to leak: the first build() succeeded and populated myShape, and CreateSmoothed's new myStatus = ProfilesInconsistent; return; unwinds through Build()'s terminal if (myStatus != Done) { NotDone(); return; } — which flips the done flag but does not clear myShape. Whether loft.shape comes back nil therefore rests entirely on the bridge going through BRepBuilderAPI_MakeShape::Shape()'s StdFail_NotDone, which nothing in this diff or this suite checks.

Add #expect(loft.shape == nil) after line 454. It costs one line and it is the assertion that would actually catch a stale-result regression in the reused-builder path #913 reported.

(Related to round-1 finding 2, but distinct: that one is about myEdgeFace/generatedFace(from:) on the C++ side and is being closed by #912. This is about shape and about the test asserting the contract at all.)

12 findings, all addressed or explicitly documented.

Finding 1 (critical, confirmed live via this PR's own first CI run,
which crashed swift build + test (macOS) with exactly the predicted
SIGSEGV): mismatchedSectionEdgeCountWithoutCheckFailsCleanly needs
patch 0027, which isn't in Package.swift's pinned kernel asset. SwiftPM
runs every test target in one process, so an unguarded crash aborts the
whole suite for every future PR until the pin moves -- the #585 failure
shape. Gated on @test(.enabled(if:
ProcessInfo.processInfo.environment["OCCTSWIFT_LOCAL"] == "1")),
matching #905/PR #909's precedent of adding no Swift test at all for
the identical reason. kernel-integration.yml sets OCCTSWIFT_LOCAL=1
when it builds Scripts/patches/ from source, so the test still runs
(and is verified) there.

Finding 3 (real, verified empirically before documenting): the guard's
inequality test already rejected fewer-edge sections, not just more --
previously silent, invalid-but-"successful" results, not just crashes.
Documented accurately everywhere (patch header, README, CLAUDE.md, this
PR's own SemVer note, which previously undersold this direction).

Finding 4: the punctual-section exemption now also verifies a section
has at least one edge before exempting it (w1Point/w2Point are
vacuously true for a genuinely empty wire). Attempted to reproduce a
live crash for this via both a fresh and reused builder and could not
-- documented as a hardening, not a proven fix; the GTest written for
it could not be made to fail without the check and was removed rather
than kept as unproven coverage.

Finding 5: added
punctualApexWithMatchingSectionsStillSucceedsUnderCreateSmoothed --
nothing previously pinned a legitimate cone-apex loft succeeding once
#913's guard reaches CreateSmoothed (3+ sections). Doesn't depend on
patch 0027 (the exemption itself is unmodified pre-existing behavior),
so it isn't gated; verified passing against both the pinned and the
patched kernel.

Finding 6: added a CLAUDE.md Known OCCT Bugs entry, matching every
other carried patch.

Finding 7: committed a reproducer at
Scripts/repro/913-thrusections-createsmoothed-section-edge-count-guard/,
matching #905's own convention (README + standalone .mm +
stock.txt/patched.txt).

Finding 8: converted guard-let-else-return setup to try #require(...).

Finding 9: hoisted the punctual-section predicate into a lambda shared
with the pre-existing fill loop, in both the local patch and the
already-submitted upstream OCCT#1466 PR (new commit pushed there).

Finding 11: fixed the factually wrong "(zero) edge count" comment --
AddVertex() creates a wire with exactly one degenerate edge, not zero.

Finding 12: SIGSEGV and SIGBUS were both genuinely observed, in
different binaries (a custom-handler standalone reproducer vs. a GTest
binary with OS default signal handling) -- not a contradiction to
resolve to one signal, exactly what heap corruption looks like.
Documented both, with which binary showed which.

Findings 2, 10: no action needed / documented rationale for leaving
the check's placement as-is (see the patch's own updated writeup).

Verified against a full local kernel rebuild with all 17 patches
(patch 0027 v2 correctly recognized as "already applied", confirming
the regenerated patch file matches): full swift test 5534/5534 passing,
all 6 static gates clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@gsdali

gsdali commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a response to the review's findings (commit 702ff2c):

  • Fixed, and confirmed live (finding 1, the load-bearing one): mismatchedSectionEdgeCountWithoutCheckFailsCleanly
    did SIGSEGV swift build + test (macOS) — checked this PR's own first CI run, which had
    genuinely failed with SIGSEGV 'segmentation violation' detected, exactly as predicted. Gated
    with @Test(.enabled(if: ProcessInfo.processInfo.environment["OCCTSWIFT_LOCAL"] == "1"));
    verified it correctly skips without the env var and runs (and passes, against the rebuilt
    kernel) with it.
  • No action needed (finding 2): already resolved by PR fix(#910): guard ThruSectionsBuilder.generatedFace(from:) on IsDone() #912's own fix, cross-referenced only.
  • Fixed, verified empirically before documenting (finding 3): built a probe confirming the
    exact claim — against the pristine kernel, a 2/1/3-edge input under checkCompatibility(false)
    reports Build() == true and Shape().IsValid() == false. The guard's inequality test already
    rejected this (no code change needed there), but the patch header, README, CLAUDE.md, and this
    PR's own SemVer note all previously undersold it as "crash → failure" only. Corrected everywhere.
  • Fixed (finding 4): the punctual-section exemption now also verifies a section has at least
    one edge before exempting it. Tried to reproduce a live crash for the zero-edge case, including
    via a reused builder — couldn't. The hardening is kept (correct, cheap, matches what the
    exemption is supposed to mean), but documented honestly as unconfirmed rather than claimed as a
    proven fix; the GTest I first wrote for it turned out to pass even against pristine, unpatched
    code, so I removed it rather than ship coverage that doesn't prove anything.
  • Fixed (finding 5): added punctualApexWithMatchingSectionsStillSucceedsUnderCreateSmoothed
    nothing previously pinned a legitimate cone-apex loft succeeding once this patch's guard reaches
    CreateSmoothed() (3+ sections). Verified passing against both the pinned and the patched
    kernel, since the exemption itself is unmodified pre-existing behavior.
  • Fixed (finding 6): added a CLAUDE.md Known OCCT Bugs entry, matching every other carried
    patch.
  • Fixed (finding 7): committed a reproducer at
    Scripts/repro/913-thrusections-createsmoothed-section-edge-count-guard/, matching ThruSectionsBuilder(isSolid: true) silently omits both end caps for a non-planar closed section wire (k >= 2 out-of-plane periods) #905's own
    convention.
  • Fixed (finding 8): guard let ... else { return }try #require(...).
  • Fixed (finding 9): hoisted the punctual-section predicate into a lambda shared with the
    pre-existing fill loop, in both the local patch and the already-submitted
    OCCT#1466 (new commit pushed there).
  • Documented, not restructured (finding 10): left the check where it is; the patch's own
    writeup now explains why (re-deriving myNbEdgesInSection from Build()'s existing "no check"
    loop isn't equivalent — it's a max, not section 1's own count — and moving the check would touch
    a more central, higher-risk function for a duplication concern, not a correctness one).
  • Fixed (finding 11): AddVertex() creates a wire with exactly one degenerate edge, not
    zero — corrected the comment (and my own earlier README text) everywhere it appeared.
  • Fixed (finding 12): both signals were genuinely observed, in different binaries — a
    custom-handler standalone reproducer showed SIGSEGV, a GTest binary with OS default signal
    handling showed SIGBUS. Not a contradiction to resolve to one; documented both, with which
    binary showed which.

Re-verified end to end after all of the above: full local kernel rebuild (all 17 patches, patch
0027 v2 correctly recognized as "already applied" against my in-place source edits, confirming the
regenerated patch file matches byte-for-byte), full swift test 5534/5534 passing, all 6 static
gates clean.

gsdali and others added 2 commits August 16, 2026 16:46
…createsmoothed-section-edge-count-guard

Conflict in Tests/OCCTStressTests/StressBuilderLifecycleTests.swift:
both #915 (this branch) and #912 (just-merged, via origin/main) added
independent new tests right after ThruSectionsBuilder's doubleBuild()
test. Resolved by keeping both sets of additions, no substantive
overlap.
Per okf/policies/changelog-on-merge.md: transcribed verbatim from the
PR body as the last commit before merging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gsdali added a commit that referenced this pull request Aug 16, 2026
code-style fix: merged origin/main (brings in PR #912's already-merged
"# deferred, see #917" manifest annotation for OCCTBridge_Modeling.mm);
updated that annotation to note PR #918 hit the identical wall days
later — every PR touching this file does, not just #912's. Resolved a
real conflict in Scripts/style-manifest-swift.txt (this branch removed
SectionBuilder.swift, an unrelated already-merged PR removed
Section2D.swift — kept both removals).

Self-review, independently verified rather than trusting the PR body's
claims at face value:

- Re-ran the repro's before/after comparison myself (not just read the
  transcripts): compiled the unmodified bridge, reverted the full fix
  via `git apply -R`, and reproduced the claimed SIGSEGV directly
  (exit 139, matching before.txt). A first attempt at this reverted
  only OCCTSectionBuilderBuild and got a false negative — the driver's
  own Init1Shape call, still carrying the OTHER half of the fix,
  independently reset `built` before the crash trigger could fire.
  Redid it as a full reverse-patch to get an authentic revert.
- Re-ran ancestorFaceNilAfterReinitWithoutRebuild's "prove the test
  fails" claim by injecting the same defect (removing Init1Shape's
  reset) myself: failed with both assertions, matching the PR body.
- Verified the Shape.box(origin:...) "corner not center" fixture-bug
  claim directly against the doc comments.
- The one question PR #912's own history raised but this PR's body
  didn't address: does OCCTSectionBuilder have #912's finding-1 class
  of gap (a `built` flag insufficient because internal state
  accumulates across builds, needing a membership check too)? Checked
  the actual OCCT source: myDSFiller is delete+new'd fresh on every
  Build() (BRepAlgoAPI_BuilderAlgo::IntersectShapes), architecturally
  the OPPOSITE of ThruSections' additive-only myEdgeFace map — a
  simple built flag is genuinely sufficient here, confirmed empirically
  via a throwaway probe (setApproximation/computePCurveOn1/2 not
  resetting `built` is correct, not a gap: SectionBuilder has no cached
  .shape accessor to go stale, unlike ThruSectionsBuilder — build()
  always re-executes fresh).
- Found and fixed one real gap: no fenced ```swift snippet on the
  changed public API (CLAUDE.md's Documentation Standards, the same
  finding-9 class from PR #912's own round 2) — added to
  ancestorFaceOn1(edge:)/ancestorFaceOn2(edge:), plus - Parameter/
  - Returns structure to match.

All 5 required static gates clean (code-style still red for the
tracked, deferred #917 reason only — same as #912/#915). Full
`swift test` (5611/5611) passes. SectionBuilder-specific suites
(11/11) re-verified after every change.
…createsmoothed-section-edge-count-guard

Conflict in docs/CHANGELOG.md: this branch's #913 entry and PR #921's
batch-transcribed entries (#876, Pass 2a, scope-boundary policy) both
added content right after '## Unreleased'. Resolved by keeping #913's
entry first (newest), followed by #921's entries, matching the
newest-on-top convention.
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.

ThruSectionsBuilder checkCompatibility(false) + mismatched section edge counts SIGSEGVs on a reused builder

2 participants