Skip to content

Debug code in bentleyOttmann writes to /tmp, prints to stdout, and races #387

Description

@j-modernc-org

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions