utmapp / utmapp/UTM

macOS: use-after-free crash in CA commit (ERROR_CGDataProvider_BufferIsNotReadable) from SPICE screenshot NSImage — recurrence of #4009

Open
#7,745 6 comments 1 reaction 0 assignees View on GitHub
Dominant language
Swift
Stars
35.5k
Forks
1.8k
Avg merge
5d 5h
Merged PRs (30d)
7

Description

## Summary

UTM 4.7.5 (118) crashes on the main thread inside CoreAnimation's commit with `EXC_BAD_ACCESS` in `ERROR_CGDataProvider_BufferIsNotReadable`. This is a **use-after-free** of the SPICE display canvas buffer that backs the VM screenshot `NSImage`. It is the same crash signature as the previously-closed #4009 — that fix only covered the PNG-save path; the **display path is still affected**.

## Environment

- UTM 4.7.5 (118), macOS 26.5.1 (25F80), Apple M2 Pro (Mac14,9)
- QEMU backend (SPICE), GL-enabled display
- Crash occurred shortly after wake from sleep (`Time Since Wake: 16 seconds`), after ~3 days of uptime

## Crash backtrace (Thread 0, main thread)

```
0 CoreGraphics ERROR_CGDataProvider_BufferIsNotReadable + 44
1 CoreGraphics CGDataProviderRetainBytePtr + 260
2 CoreGraphics CGImageCreate + 380
3 CoreGraphics CGImageCreateCopy + 208
4 QuartzCore CA::Render::create_image_by_rendering(...) + 724
5 QuartzCore CA::Render::copy_image(...) + 4976
6 QuartzCore -[CALayer(CALayerPrivate) _copyRenderLayer:layerFlags:commitFlags:] + 860
...
14 QuartzCore CA::Context::commit_transaction(...) + 11208
15 QuartzCore CA::Transaction::commit() + 648
16 QuartzCore CA::Transaction::flush_as_runloop_observer(bool) + 140
17 AppKit stepTransactionFlush + 220
```

`VM Region Info` confirms the faulting address `0x4d8400000` lies in an unmapped GAP immediately after a freed `MALLOC_SMALL` region — i.e. CoreAnimation tried to read pixel bytes that had already been freed.

## Root cause

The screenshot `CGImage` does not own its pixel buffer — it points directly at the SPICE-owned display canvas, with no copy and no release callback.

`CocoaSpice/Sources/CocoaSpice/CSDisplay.m`, `-screenshotWithCompletion:` (pinned rev `52b1535824657354fc3089eab24f1827280f9143`):

```objc
if (self.canvasData && self.isGLEnabled) {
CGColorSpaceRef colorSpaceRef = CGColorSpaceCreateDeviceRGB();
CGDataProviderRef dataProviderRef = CGDataProviderCreateWithData(NULL,
self.canvasData, // raw pointer into SPICE memory
self.canvasStride * self.canvasArea.size.height,
nil); // no copy, no release callback
img = CGImageCreate(..., dataProviderRef, ...);
...
}
```

`CGDataProviderCreateWithData(..., releaseData: NULL)` does **not** copy the bytes; the resulting `CGImage` reads `self.canvasData` lazily. `[[NSImage alloc] initWithCGImage:]` retains the `CGImage` but likewise does not deep-copy the pixels.

The buffer's lifetime is owned by SPICE: it is set in `cs_primary_create` (`self.canvasData = imgdata;`, line 82) and freed/cleared in `cs_primary_destroy` (`self.canvasData = NULL;`, line 95) on surface teardown / resolution change / GL transition.

### Why it races (cross-thread)

- `screenshotWithCompletion:`, `cs_primary_create`, and `cs_primary_destroy` all run serialized on the **SPICE glib main context** (`CSMain asyncWith:`/`isCurrentContextMain`), so *creating* the `CGImage` does not race with *freeing* the buffer.
- But the resulting `NSImage` is handed to UTM and assigned to a CALayer-backed view on the **AppKit main thread**:
`Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift:171`
```swift
override func enterSuspended(isBusy busy: Bool) {
if !busy {
metalView.isHidden = true
screenshotView.image = vm.screenshot?.image // dangling-backed NSImage -> CALayer
screenshotView.isHidden = false
}
...
```
- CoreAnimation reads the pixel bytes lazily during the next render commit on the main thread. If SPICE has freed `canvasData` (on the glib context) in the meantime, CA reads freed memory → crash. The two threads are unsynchronized, hence the intermittent nature (and the correlation with wake-from-sleep, where a redraw is forced).

## Relationship to #4009

#4009 (closed COMPLETED, 2024-02-26) is the same `ERROR_CGDataProvider_Buffer*` / `CA::Context::commit_transaction` signature. The applied mitigation computed PNG data early (now `UTMVirtualMachineScreenshot.createData(from:)` in `Services/UTMVirtualMachine.swift`), which protects only the **save-to-disk** path. The **display** path still keeps the lazy `NSImage` alive (`vm.screenshot?.image` assigned to `screenshotView`), so the use-after-free recurs. Suggest reopening #4009 or tracking this as its own.

## Trigger conditions

- Periodic 60s screenshot timer (`startScreenshotTimer`, `Services/UTMVirtualMachine.swift`) and screenshots taken on pause/stop, producing a long-lived `NSImage` referencing live SPICE memory.
- A subsequent SPICE surface teardown/reconfigure (`cs_primary_destroy`) frees that memory.
- A main-thread redraw of `screenshotView` (e.g. entering suspended state, or a forced redraw after wake) commits the layer and reads freed bytes.

## Suggested fix (CocoaSpice)

In `-screenshotWithCompletion:`, make the `CGImage` own its pixels so its lifetime is independent of the SPICE buffer. Either:

- copy the bytes and pass a release callback to `CGDataProviderCreateWithData` (a real C `CGDataProviderReleaseDataCallback` that `free()`s the copy), or
- use `CGDataProviderCreateWithCFData`/a `CFData` copy, or
- render into an owned bitmap context.

This keeps the screenshot self-contained and removes the cross-thread dependency on `canvasData`'s lifetime.

## Notes / observations

- The branch condition is `self.canvasData && self.isGLEnabled` — when GL is enabled, the screenshot reads the *software* canvas buffer rather than the GL texture, which makes the dangling-buffer situation more likely around GL/non-GL surface transitions. Worth confirming this branch is intended.
- Workaround for affected users (no fix): launch with screenshots disabled — `defaults write com.utmapp.UTM NoScreenshot -bool YES` (revert with `defaults delete com.utmapp.UTM NoScreenshot`). Note `NoSaveScreenshot` alone does **not** avoid this crash, since the live `NSImage` is still shown.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.