# NSImage(contentsOfFile:) blocked for 327 seconds: reading a wallpaper while Spotlight indexes

> NSImage(contentsOfFile:) blocked for 327 seconds on a 500 KB wallpaper while Spotlight indexed. The ImageIO fix, a fresh measurement, and what remains unproven.

Canonical: https://www.notchstar.com/blog/nsimage-contentsoffile-blocked-327-seconds/
Last updated: 2026-11-04

*One stall in a demo video recorder, the three changes that removed it, and what was never isolated*

Published 2026-11-04.

## TL;DR

- Once, while a demo recorder was running, `NSImage(contentsOfFile:)` on a 500 KB wallpaper blocked for 327 seconds, while Spotlight was indexing. The first recorded frame took about 70 seconds, and the first hero clip came out at 43 seconds, 12 of them an idle closed notch.
- The fix is three changes in `Tests/NotchStarTests/Video/ClipRecorder.swift`: read the file as `Data`, decode it with ImageIO, and draw it once into a canvas sized bitmap before recording starts.
- The cause was never isolated. The 327 and 70 second figures were observed once in logs, not benchmarked, and could not be reproduced on 4 November 2026. A fresh measurement of the idle case is in this post.

## The problem: a recording that was mostly waiting

NotchStar records its demo videos without Screen Recording permission. A test renders the real notch view in an off screen panel at about 30 frames per second, drives it with synthetic mouse and key events, and writes the frames to a `.mov` file with `AVAssetWriter`. The pipeline is the subject of an earlier post in this series. This post is about one stall inside it.

The clips can sit on a desktop picture, so the recorder takes a wallpaper. `scripts/make-videos.sh` converts a system wallpaper to a JPEG with `sips` and passes its path in an environment variable:

```sh
sips -s format jpeg -s formatOptions 88 -Z 2600 "/System/Library/Desktop Pictures/Sonoma.heic" --out "$(pwd)/build/wallpaper.jpg" >/dev/null
TEST_RUNNER_NOTCH_WALLPAPER="$(pwd)/build/wallpaper.jpg" ...
```

The symptom, from the project's notes of what went wrong while building it:

| What | Figure |
|---|---|
| `NSImage(contentsOfFile:)` on the wallpaper (about 500 KB) | blocked for 327 seconds, once |
| First recorded frame | about 70 seconds |
| First hero recording | 43 seconds, of which 12 seconds were an idle closed notch |

The 43 second hero is the visible end of the same stall: the recorder timestamps frames in real time, so a long wait at the start is recorded as a long idle notch at the start of the clip. A stall in setup became content.

```text
 recorder starts
   |-- load wallpaper (blocked) -----------------------------> frames can start
   |   [ NSImage, Spotlight indexing the file ]
   |
 clip timeline:  [ idle closed notch ........ 12 s ][ the real script ... ]
```

The sentence in the code comment is the whole report: "NSImage(contentsOfFile:) once blocked for minutes on this file while Spotlight was indexing."

The wording matters. The numbers were seen in a run's log, once. The project notes say they come from logs, and no log is referenced in the repository, so they cannot be checked from the code. The earlier post in the series says the same: observed once, not a benchmark.

## The code today

The current loader is six lines. This is the real one:

```swift
static let wallpaper: CGImage? = {
    // Raw data + ImageIO: NSImage(contentsOfFile:) once blocked for minutes on this file while Spotlight was indexing.
    guard let path = ProcessInfo.processInfo.environment["NOTCH_WALLPAPER"],
          let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
          let src = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
    return CGImageSourceCreateImageAtIndex(src, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary)
}()
```

Two details deserve a note. `Data(contentsOf:)` reads the whole file into memory in one call, and then ImageIO decodes from memory, so nothing lazy touches the file again. And `kCGImageSourceShouldCacheImmediately: true` asks ImageIO to decode now, not when the image is first drawn.

There is no "before" diff to show. The broken line was never committed. The first commit that has the wallpaper code (`e7071cc`, 7 October 2026) already contains the ImageIO version and the comment about NSImage, so the history only holds the repaired code. The description of what was wrong comes from that comment and from the project's notes.

## Three changes, not one

The loader is only the first change. Reading the recorder shows three things working together.

**1. Raw data and ImageIO.** The file is read as bytes and decoded with `CGImageSource`, not through `NSImage`'s path based loader.

**2. A prerendered backdrop.** The wallpaper is drawn once into a bitmap the size of the output canvas, and each frame draws that bitmap. The comment says why: "decoding and scaling it every frame was far too slow."

```swift
private lazy var backdrop: CGImage? = {
    guard let wp = Self.wallpaper else { return nil }
    let W = Int(style.canvas.width), H = Int(style.canvas.height)
    guard let c = CGContext(data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0, /* sRGB, 32 bit */) else { return nil }
    let fill = max(CGFloat(W) / CGFloat(wp.width), CGFloat(H) / CGFloat(wp.height))
    /* draw centered at scale `fill`, high quality */
    return c.makeImage()
}()
```

**3. Forcing it before the clock starts.** The recorder's initializer touches the backdrop before the first frame:

```swift
_ = backdrop     // decode and scale the wallpaper now, not inside the first recorded frame
```

That line is the one that matters for the 43 second clip. Whatever the decode costs, it must be paid before the timeline begins, not inside frame one.

The post cannot say which of the three removed the stall. They were made together, and no run isolated them. A fair reading is that change 3 fixed the symptom (a stall inside the first frame) and changes 1 and 2 reduce the work and the exposure to whatever blocked.

## Why the recorder cannot simply wait

A recorder that works in real time has one rule: nothing slow may happen between the first timestamp and the last. Each frame does a fixed amount of work. It lays out the hosted view, asks it to draw into a bitmap, composes that over the backdrop with the cursor and click ripples, and appends the result to the writer. Per the project's facts a capture takes about 23 ms, which is inside the 33 ms a 30 frames per second budget allows. The loop that drives it is built on a clock:

```swift
while true {
    let t = now
    update?(min(1, duration > 0 ? (t - start) / duration : 1))
    frame()
    if now - start >= duration { break }
    nextFrameAt = max(nextFrameAt + 1 / fps, now)
    try? await Task.sleep(for: .seconds(max(0.001, nextFrameAt - now)))
}
```

Presentation times come from the wall clock (`var pts = time - (t0 ?? time)` in `frame()`), so a slow frame is recorded as a slow frame, and a stall at the start is recorded as time that passed with nothing moving. That is how a setup problem turned into 12 seconds of an idle closed notch. A separate `warmUp` method exists for settling the interface without recording, and it is the right tool for UI state. It could not help here, because the stall was inside the first frame's own drawing path, not in the interface.

## What was likely going on, and what is only a guess

The facts are: a file about 500 KB, written moments earlier by `sips` into a `build` folder, read by `NSImage(contentsOfFile:)`, during a period when Spotlight was indexing, with the call blocked for 327 seconds.

A plausible story is that a freshly written file in a folder under indexing was busy with another process when the read began. That is a hypothesis. It fits the one observation and it was not tested, so it should not be treated as the cause.

There is a second question the fix leaves open. `Data(contentsOf:)` reads the same file. If the file itself were the blocked resource, the new loader would block too. The project notes record no repeat of the stall since the change, but no controlled comparison exists: same file, same machine state, both loaders. The honest summary is that the stall has not been recorded again, and why is not established.

## Measuring the idle case

The one thing that can be measured today is how the two loaders behave when nothing is stalling. The script times each loader on the same JPEG, five rounds, with `NSImage` asked for its `CGImage` so decoding is included:

```swift
import AppKit
import ImageIO
let path = CommandLine.arguments[1]
func ms(_ f: () -> Void) -> Double { let t = CFAbsoluteTimeGetCurrent(); f(); return (CFAbsoluteTimeGetCurrent() - t) * 1000 }
for i in 1...5 {
  let a = ms { let im = NSImage(contentsOfFile: path); _ = im?.cgImage(forProposedRect: nil, context: nil, hints: nil) }
  let b = ms {
    if let d = try? Data(contentsOf: URL(fileURLWithPath: path)), let s = CGImageSourceCreateWithData(d as CFData, nil) {
      _ = CGImageSourceCreateImageAtIndex(s, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary)
    }
  }
  print("run \(i): NSImage \(String(format: "%.1f", a)) ms, ImageIO \(String(format: "%.1f", b)) ms")
}
```

Results on 4 November 2026, macOS 27.0.1, with the JPEG made by the same `sips` command as the script (537,546 bytes, 2600 by 2600 pixels):

| Run | NSImage (ms) | ImageIO (ms) |
|---|---|---|
| 1 | 40.6 | 17.5 |
| 2 | 1.2 | 13.5 |
| 3 | 0.5 | 12.8 |
| 4 | 0.5 | 13.9 |
| 5 | 0.4 | 14.2 |

What this shows, and what it does not:

- On an idle Mac, neither loader is slow. The first `NSImage` call took 40.6 ms and the first ImageIO call 17.5 ms. Nothing here resembles 327 seconds, and the stall was not reproduced.
- `NSImage` is much faster after the first call, which fits it keeping its decoded result. ImageIO, with immediate caching, decodes the whole image on every call, so each run costs roughly 13 to 14 ms. For a loader that runs once per recording that is irrelevant.
- So the change was not a speed up. In the idle case it is a wash. It exists for the unusual case, where the loader waits on something outside the process.
- `mdutil -s /` printed "Indexing enabled." on this Mac at the time. Whether Spotlight was actively indexing the test file during these runs is unknown.

The numbers say nothing about how the recorder performs. They are the cost of one image decode on one Mac.

## How to look at a stall like this

When a call blocks for minutes, guessing is expensive. Two tools in macOS show where the time goes, and neither was needed for the fix, because it was not caught live. The syntax of the first was checked on a sleeping process on 4 November 2026, and the second needs root, so it was not run:

```sh
sample <pid> 5 -file /tmp/stall.txt     # five seconds of stack samples of the stuck process
sudo fs_usage -w -f filesys <pid>       # file system calls it is waiting in, as they happen
mdutil -s /                             # is Spotlight indexing enabled on this volume
```

A stack sample that shows the main thread inside a file read, with the file name in the `fs_usage` output beside it, turns "Spotlight was indexing" from a story into evidence. This is what should have been captured at the time, and is the first thing to do if it happens again.

## What is still bad

- The cause is not proven. "Spotlight was indexing" is what was seen at the time, not what was shown.
- The figures 327 and 70 seconds are single observations from a log that is not in the repository.
- The three changes were never separated, so it is not known which one matters.
- The new loader reads the same file, so if the file itself was the blocked resource, the same stall could happen again.
- The recorder reads the wallpaper synchronously in an initializer on the main actor. A hung read would still hang the recording.
- The check is manual: nothing in the test suite fails if the initializer takes too long.

## Try it yourself

1. Make a JPEG of about 500 KB: `sips -s format jpeg -s formatOptions 88 -Z 2600 "/System/Library/Desktop Pictures/Sonoma.heic" --out wallpaper.jpg`.
2. Save the timing script above as `t.swift`, compile it with `swiftc -O t.swift -o t`, run `./t wallpaper.jpg`.
3. Compare your first run with the later ones. If your first `NSImage` call is slower than the rest, the difference is decoding and caching, not stalling.
4. To study a real stall, write a file and read it at once while the system is busy, and run `sample` on the process while it waits.
5. In your own code, put any slow resource load before a real time clock starts, and add a log line with the elapsed time so a stall shows itself.

## Cheat sheet

| Situation | Do this |
|---|---|
| A resource read stalls for minutes | Capture `sample <pid>` while it is stuck, not after |
| An image needed every frame | Decode and scale it once into a bitmap of the final size |
| A real time recorder | Pay every setup cost before the first timestamp |
| Decode should happen now, not on first draw | `kCGImageSourceShouldCacheImmediately: true` |
| File reads that touch a busy path | Read bytes with `Data(contentsOf:)`, decode from memory |
| You cannot reproduce a number | Say it was observed once, and say where it was seen |

- [ ] Setup work happens before the clock starts
- [ ] Per frame work is only drawing a finished bitmap
- [ ] The stall is captured with `sample` the next time it happens
- [ ] Observed once is written as observed once

## Close

NotchStar is the app these recordings show. If you want to try it, it is at [notchstar.com](https://www.notchstar.com).

## More posts

- [Camera, Calendar, Reminders and Apple Events: the four permissions a notch app asks for](https://www.notchstar.com/blog/four-permissions-a-notch-app-asks-for/)
- [A Swift Task that never ran: RunLoop.run, the main actor and Task.sleep](https://www.notchstar.com/blog/swift-task-never-ran-runloop-main-actor/)
