Repository navigation
fix(🎨): wait for the draw in flight before a canvas is detached on another runtime - #4356
YevheniiKotyrlo wants to merge 4 commits into
Conversation
…other runtime A Skia object crosses into worklet runtimes by reference, and the owner of a canvas can let go of it from any of them: a surface's dispose(), or the end of a picture recording or of a Graphite context's recording. JsiSkCanvas handed each draw the raw SkCanvas* and detach() cleared it with nothing ordering the two, so a dispose() on one runtime could free the SkCanvas under a draw in flight on another. A draw now holds the canvas until it returns (getCanvas() hands it out with the canvas's lock), and detach() waits for it. finishRecordingAsPicture() and the Graphite context's finishRecording() now detach the canvas before they end the recording. The next draw throws, as after a dispose() on the same runtime.
ReproductionThe two cases this PR adds are the reproduction. With the example app running on an Android device in e2e mode (Metro started with E2E=true CI=true yarn test -i "e2e/(Surfaces|Picture).spec" -t "on another runtime"A worklet runtime made by Before this PR, With this PR — both pass, on the Mi MIX 3 and on an iPhone 12 Pro Max (iOS 27.0.1, Release build). Over five rounds of each on the Mi MIX 3, Device measurement — before / after
This PR — each call also takes and releases the canvas's mutex, which no other thread holds in the bench:
Mi MIX 3 (Android 10), release builds of The bench screen, each launch's readings and the script that reads them back into the table, the timing probe behind the numbers above and the two crashes' backtraces are in my evidence repository, not in the PR: https://github.com/YevheniiKotyrlo/react-native-skia-evidence/tree/main/pr-4356. |
# Conflicts: # packages/skia/cpp/api/JsiSkCanvas.h

Problem
Disposing a surface on one runtime frees its canvas, and finishing a picture recording ends it, under a draw another runtime has in flight on that canvas. Skia objects cross into worklet runtimes by reference, and
JsiSkCanvashands each draw a raw pointer thatdetach()clears with nothing ordering the two:A surface disposed on the thread that made it is destroyed at once (
Dispatcher::release()), itsSkCanvaswith it, andfinishRecordingAsPicture()and the Graphite context'sfinishRecording()end the recording before they detach the canvas. Any app that disposes a surface, or ends a recording, on one runtime while another may be drawing on its canvas meets it: a cleanup on the JS thread while auseFrameCallbackworklet draws, for one. Onmain, the cases below crash the example on Android: SIGSEGV on the JS thread inJsiSkCanvas::drawSvg>SkCanvas::restoreToCount, and on the worklet's thread infinishRecordingAsPicture()>SkRecordFillBounds.Solution
A draw holds the canvas until it returns, and
detach()waits for it.getCanvas()returns a guard that holds the canvas's mutex and is destroyed at the end of the statement, sogetCanvas()->drawRect(…)reads as before;drawSvg,Paragraph.paint()andSkottie.render()keep it for the whole call, andreadPixelswhile it reads, letting go of it before its promise is made.detach()takes the same mutex, so it waits for the draw in flight; the surface still goes to its thread's dispatcher. The picture recorder and the Graphite context now detach the canvas before they end the recording. Adispose()on another runtime therefore waits for the one draw call in flight, and the next draw throws the same error as after adispose()on the same runtime.No JS runs while a canvas is held, since every method converts its arguments before
getCanvas(), so a callback can never detach the canvas its own draw holds. Native code that callsgetCanvas()gets the guard:getCanvas()->draw…compiles as before, and.get(), which hands out the raw pointer, compiles only on a guard kept in a variable.Tests
Two e2e cases, in
Surfaces.spec.tsxandPicture.spec.tsx: a worklet runtime (createWorkletRuntime, helpers inapps/example/src/Tests/workletThread.ts) disposes a raster surface it made, or finishes a picture recording, a quarter of the way into a draw on the JS thread that takes a few hundred milliseconds (slowSvg: 262,144 squares, nine levels of nested<use>), the quarter counted from the draw's start against the same draw timed beforehand. Each asserts that the worklet's call started during the draw and was blocked for at least half of what was left of it, and that the JS thread's next draw throws; the recording's case also checks that the picture holds the whole draw. Both crash onmain. The lock alone is not enough: withfinishRecordingAsPicture()detaching after the finish, the recording's case still crashes.Verification
cpplint, lint, tsc, test, test-cpp, test-web, build-library and the JVM unit tests pass locally · on a Mi MIX 3 (Android 10), release builds: the two new cases pass, and the other cases of Surfaces, Picture, GraphiteView, Readback, ParagraphPaint, Skottie and SVG pass as on
main· the changed headers compile with the iOS and macOS SDKs (Xcode 27.1) with no new warnings · the two new cases pass on an iPhone 12 Pro Max (iOS 27.0.1), Release build. That was on60170c857; on this head, which mergesmain3184116c9, cpplint, lint and tsc pass, the Picture and Surfaces specs pass on the Mi MIX 3 with the two new cases, and the changed headers compile with the iOS and macOS SDKs withmain's warnings.Cost: each call from JS through a canvas takes and releases a mutex nobody else holds: on a Mi MIX 3,
getSaveCount424 → 456 ns, this branch slower by 19–44 ns in each of six pairs of back-to-back launches, wheremain's own six launches spread 45 ns; adrawRectmoves less thanmain's launches spread (1102 → 1151 ns on a recording canvas against 266 ns, 2188 → 2295 ns on a raster one against 283 ns). The<Canvas>renderer draws in C++ and does not go through it. The evidence comment has the reproduction, the crashes and the measurement.No existing issue tracks this — searched for
dispose canvas worklet,canvas use after free,dispose surface worklet,getCanvas dispose,canvas detachedandfinishRecordingAsPicture worklet, zero hits.Related
#4278 made
dispose()detach the canvas so that a later draw throws; this covers a draw already in flight on another runtime.#4353 posts a surface released off the thread that made it to that thread's loop; a
dispose()on that thread, the case here, still destroys the surface at once.Three related races stay outside it: a Graphite context disposed or collected with its recording open never detaches its canvas;
getObject()on one runtime copies an object'ssk_spwhiledispose()on another resets it; and a surface'smakeImageSnapshot(),flush()andasImage()take no lock against a draw on its canvas.