Hi - thanks for canvas, we lean on it heavily.
path_intersection.go carries what looks like leftover debugging in toleranceSquares.breakupCrossingSegments (v0.0.0-20260708151538-b3beae08c578, line 1553; present in every version we have back to 2026-04):
square.Lower = prev
if square.Upper == nil {
// TODO: this happens sporadically, add to unit tests
w, _ := os.CreateTemp("", "canvas-testcase-*.gob")
gob.NewEncoder(w).Encode([]any{_ps, _qs, _op, _fillRule})
w.Close()
fmt.Println("NOTE: new test case written to", w.Name())
square.Upper = prev
}
and the package-level state it serialises, set on every call at line 1803:
var _ps, _qs Paths
var _op pathOp
var _fillRule FillRule
func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRule) Paths {
...
_ps, _qs, _op, _fillRule = ps, qs, op, fillRule
We understand why it is there - you want the input that triggers the case. Three consequences make it awkward for a library, and one of them is a bug independent of the debugging.
1. _ps/_qs/_op/_fillRule are a data race
bentleyOttmann writes those globals unconditionally, so any two goroutines doing boolean path operations race. This is the part we would flag even if the debug branch went away: concurrent rasterisation is a normal thing to do, and nothing in the API suggests Path.And is not safe to call from more than one goroutine.
func TestConcurrentBooleanOps(t *testing.T) {
a := canvas.Rectangle(100, 100)
b := canvas.Rectangle(50, 50).Translate(25, 25)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); for k := 0; k < 50; k++ { _ = a.And(b) } }()
}
wg.Wait()
}
WARNING: DATA RACE
Write at 0x000000bd6ba0 by goroutine 17:
github.com/tdewolff/canvas.bentleyOttmann()
path_intersection.go:1803
github.com/tdewolff/canvas.(*Path).And()
path_intersection.go:148
Previous write at 0x000000bd6ba0 by goroutine 10:
... same two frames
2. It panics when the temp file cannot be created
os.CreateTemp's error is dropped, and (*os.File).Name has no nil check - Close does, and gob only returns an error, so w.Name() is where it lands:
CreateTemp err = open /nonexistent/x-1224150888.gob: no such file or directory
panic: runtime error: invalid memory address or nil pointer dereference
A container with a read-only or absent TMPDIR turns a rendering call into a panic.
3. stdout and unbounded temp files
fmt.Println to stdout is hard for an embedder to deal with: a program whose stdout is the artifact (a PDF or PNG written to a pipe) gets corrupted output, and one that emits structured logs gets a stray line in the middle. The .gob files also accumulate with no cleanup - a long-running renderer trickles files into /tmp indefinitely.
What we see
We render HTML and use canvas for the vector back end, where boolean ops implement CSS clipping. We sometimes observe:
NOTE: new test case written to /tmp/canvas-testcase-3369210964.gob
NOTE: new test case written to /tmp/canvas-testcase-652071197.gob
... 12 more on one page load
We have not managed to reduce it to a small path pair - it fires on real page content and not on anything we have been able to trim down, which we realise is exactly the problem you left the branch there to solve. If it helps, we can send you the .gob files a real page produces; they are the inputs you are trying to capture. Say the word and we will attach them, or wire up a build that saves them somewhere you would prefer.
Suggested shape
Whatever suits you, but from an embedder's side:
- Put the capture behind an opt-in - a build tag, or an exported
canvas.DebugPathIntersection io.Writer (nil by default) that the branch writes to.
That also lets a caller collect the cases for you without patching the module.
- Drop the globals, or pass the inputs down to the point of use, so concurrent boolean
ops stop racing.
- If the branch stays as is: check
os.CreateTemp's error, and print to os.Stderr
rather than stdout.
Happy to send a PR for any of these if you would like - just say which shape you prefer.
Hi - thanks for canvas, we lean on it heavily.
path_intersection.gocarries what looks like leftover debugging intoleranceSquares.breakupCrossingSegments(v0.0.0-20260708151538-b3beae08c578, line 1553; present in every version we have back to 2026-04):and the package-level state it serialises, set on every call at line 1803:
We understand why it is there - you want the input that triggers the case. Three consequences make it awkward for a library, and one of them is a bug independent of the debugging.
1.
_ps/_qs/_op/_fillRuleare a data racebentleyOttmannwrites those globals unconditionally, so any two goroutines doing boolean path operations race. This is the part we would flag even if the debug branch went away: concurrent rasterisation is a normal thing to do, and nothing in the API suggestsPath.Andis not safe to call from more than one goroutine.2. It panics when the temp file cannot be created
os.CreateTemp's error is dropped, and(*os.File).Namehas no nil check -Closedoes, andgobonly returns an error, sow.Name()is where it lands:A container with a read-only or absent
TMPDIRturns a rendering call into a panic.3. stdout and unbounded temp files
fmt.Printlnto stdout is hard for an embedder to deal with: a program whose stdout is the artifact (a PDF or PNG written to a pipe) gets corrupted output, and one that emits structured logs gets a stray line in the middle. The.gobfiles also accumulate with no cleanup - a long-running renderer trickles files into/tmpindefinitely.What we see
We render HTML and use canvas for the vector back end, where boolean ops implement CSS clipping. We sometimes observe:
We have not managed to reduce it to a small path pair - it fires on real page content and not on anything we have been able to trim down, which we realise is exactly the problem you left the branch there to solve. If it helps, we can send you the
.gobfiles a real page produces; they are the inputs you are trying to capture. Say the word and we will attach them, or wire up a build that saves them somewhere you would prefer.Suggested shape
Whatever suits you, but from an embedder's side:
canvas.DebugPathIntersection io.Writer(nil by default) that the branch writes to.That also lets a caller collect the cases for you without patching the module.
ops stop racing.
os.CreateTemp's error, and print toos.Stderrrather than stdout.
Happy to send a PR for any of these if you would like - just say which shape you prefer.