[iOS, v2/Ganesh] MetalContext over-releases the system MTLDevice once per exited thread, then Metal crashes
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 76/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- cpp, ios, objective-c
Research direction
Start with packages/skia/apple/MetalContext.mm on the 2.x branch and check the fDevice.reset call against the ownership semantics documented in include/ports/SkCFObject.h; compare with the command queue handling nearby. The issue identifies backendContext.fDevice.retain as the fix. Verify that short-lived threads no longer reduce the device retain count or crash in the provided simulator reproduction.
Written by the indexing model from the issue text.
Description
Description
On the v2 line (Ganesh), every MetalContext that is destroyed releases the system MTLDevice once more than it retained it. MetalContext is thread_local (MetalContext::getInstance()), so this happens each time a thread that drew with Skia exits. After a few such exits the device is freed while Metal still uses it, and the app crashes inside Metal (-[_MTLDevice dealloc], -[_MTLFunction dealloc], -[MTLRenderPipelineDescriptorInternal dealloc], or an XPC misuse trap under the simulator driver).
Apps whose Skia threads come and go are exposed: worklet runtimes that are torn down, frame-processor or worker threads, a JS thread recreated on reload.
Expected: the device's retain count is the same after a thread exits as before it started. Actual: it drops by one per exited thread.
Cause. In packages/skia/apple/MetalContext.mm (2.x):
_device = MTLCreateSystemDefaultDevice(); // ARC strong ivar
...
GrMtlBackendContext backendContext = {};
backendContext.fDevice.reset((__bridge void *)_device); // adopts a reference, no CFRetain
backendContext.fQueue.reset((__bridge void *)_commandQueue); // balanced by the CFRetain above
sk_cfp::reset adopts the pointer without retaining it ("No call to CFRetain() will be made", include/ports/SkCFObject.h) and releases it when backendContext goes out of scope at the end of the constructor. The __bridge cast transfers no ownership, so that release is one too many. The command queue on the next line is balanced by its explicit CFRetain; the device is not.
main (v3) has no MetalContext, so this is v2 only.
React Native Skia Version
2.14.0 and the 2.x branch (f24fc55). The line is unchanged since at least 2.6.2.
React Native Version
0.83.1 (the repo's example app). Also seen with 0.86.
Using New Architecture
- Enabled
Steps to Reproduce
- Draw with Skia on a background thread (anything that calls
MetalContext::getInstance(): an offscreen surface, a texture), then let the thread exit. - Repeat on new threads.
- After a handful of exits, the process crashes in Metal.
A standalone program reproduces it deterministically on the iOS simulator. It compiles the 2.x branch's own apple/MetalContext.mm unchanged, links the m154 prebuilt libskia.a from react-native-skia-apple-ios, and draws one circle on each of N short-lived threads (code below):
MetalContext.mm |
1 thread | 12 threads | 64 threads |
|---|---|---|---|
as on 2.x (fDevice.reset) |
OK, retain count 4 -> 3 | SIGTRAP after 4-5 threads (count 4 -> 1, then -[_MTLDevice dealloc] from MetalContext::~MetalContext()) |
SIGTRAP |
with fDevice.retain |
OK, 4 -> 5 | OK, stays 5 | OK, stays 5 |
Crashing stack (iOS 27 simulator):
libxpc.dylib _xpc_api_misuse
MTLSimDriver -[MTLSimBuffer dealloc]
Metal -[_MTLDevice dealloc]
MTLSimDriver -[MTLSimDevice dealloc]
metalctx-baseline MetalContext::~MetalContext()
libdyld.dylib dyld::ThreadLocalVariables::finalizeList(void*)
libsystem_pthread _pthread_tsd_cleanup
In an app with a small pool of worker threads that each made a Skia context and exited, a Release build on the iOS simulator crashed on 9 of 10 launches with the line as it is, and on 0 of 10 with retain.
Snack, Code Example, Screenshot, or Link to Repository
main.mm (built against packages/skia/apple/MetalContext.mm and the m154 libskia.a, iOS simulator, ARC)
// N threads each draw with their thread_local MetalContext and exit.
// Prints the system MTLDevice's retain count. Usage: metalctx <threads>
#import <Metal/Metal.h>
#include <cstdio>
#include <cstdlib>
#include <thread>
#include "MetalContext.h"
#include "include/core/SkCanvas.h"
#include "include/core/SkPaint.h"
static void drawOnce() {
@autoreleasepool {
auto &ctx = MetalContext::getInstance();
auto surface = ctx.MakeOffscreen(64, 64);
SkPaint p; p.setColor(SK_ColorRED);
surface->getCanvas()->drawCircle(32, 32, 20, p);
ctx.getDirectContext()->flushAndSubmit(GrSyncCpu::kYes);
}
}
int main(int argc, char **argv) {
const int threads = argc > 1 ? std::atoi(argv[1]) : 8;
id<MTLDevice> device = MTLCreateSystemDefaultDevice();
CFTypeRef d = (__bridge CFTypeRef)device;
std::printf("start: device retain count %ld\n", (long)CFGetRetainCount(d));
for (int i = 1; i <= threads; i++) {
std::thread t(drawOnce);
t.join(); // the thread exits: its thread_local MetalContext is destroyed
std::printf("after %3d threads: device retain count %ld\n", i,
(long)CFGetRetainCount(d));
}
std::thread t(drawOnce); t.join();
std::printf("OK threads=%d\n", threads);
return 0;
}
Built with clang++ -target arm64-apple-ios17.0-simulator -std=c++20 -fobjc-arc -x objective-c++ -DSK_METAL=1 -DSK_GANESH=1 -I packages/skia/{apple,cpp,cpp/utils,cpp/skia} -I <react-native>/ReactCommon/jsi main.mm packages/skia/apple/MetalContext.mm <libskia.a> -framework Metal -framework MetalKit -framework UIKit ... and run with xcrun simctl spawn <device> ./metalctx 12.
Fix: backendContext.fDevice.retain((__bridge void *)_device);. PR to follow.
- Dominant language
- TypeScript
- Stars
- 8.6k
- Forks
- 653
- PR merge metrics
- No merged PRs in 30d
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from Shopify/react-native-skia
-
[iOS] SIGABRT in makeImageSnapshot when canvas size is -1 — MTLTextureDescriptor width (uint64)-1 assertionPossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Shopify/react-native-skia#4029 · 3 comments ·
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
Shopify/react-native-skia#4148 · 1 comment ·
-
Android: errors Skia throws as std::runtime_error reach JS as "Exception in HostFunction: <unknown>"Open
Difficulty 4/5 3-5 days Newbie friendliness 52/100
Shopify/react-native-skia#4142 ·
-
Difficulty 3/5 1-2 days Newbie friendliness 56/100
Shopify/react-native-skia#4136 · 1 comment ·
-
Difficulty 3/5 1-2 days Newbie friendliness 74/100
Shopify/react-native-skia#4135 · 1 comment ·
All issues in Shopify/react-native-skia
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
rajbos/ai-engineering-fluency#2340 · 1 comment ·
Maintainers usually reply within 1 day
-
community documentation first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficulty 1/5 Under an hour Newbie friendliness 70/100
lingdojo/kana-dojo#31864 · 1 comment · 5 reactions ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
zenstackhq/zenstack#2873 ·
Maintainers usually reply within 1 day
-
CLI: TUI shows onboarding when the provider's API key is only in the environment (e.g. OPENROUTER_API_KEY)Possibly taken A pull request linked to this issue is open or already merged. OpenCLI
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
cline/cline#14923 · 2 comments ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
paperclipai/paperclip#15490 ·
Maintainers usually reply within 1 day