# A Swift Task that never ran: RunLoop.run, the main actor and Task.sleep

> A Swift Task never ran while a script pumped RunLoop.run. What the main actor does, the async fix with Task.sleep, and a repro that did not fail.

Canonical: https://www.notchstar.com/blog/swift-task-never-ran-runloop-main-actor/
Last updated: 2026-11-04

*Demo scans sat on Working forever while a recording script pumped the run loop. The fix was one keyword and a different kind of wait.*

Published 2026-11-04.

## TL;DR

- A scripted recording of the NotchStar UI showed "Working..." forever on screens whose sample scans were started by a `Task`. The script waited with `RunLoop.run`, a call that blocks. The fix was to make the recorder `async` and wait with `await Task.sleep`.
- The suspected reason is the main actor: it runs one piece of work at a time, and a script that blocks inside that work gives a queued `Task` no turn. A busy wait reproduces that every time. A plain `RunLoop.run` call in a small command line program did not, so the exact trigger inside the test host is not pinned down here.
- Cost of the fix: every script helper became `async`, and 13 clip tests now `await` each step. Result: the recorder runs at about 30 frames per second and the demo scans finish.

## The symptom

The website's tutorial clips come from the real notch interface rendered offscreen and driven by a script (the method is in the post about recording demo videos without screen recording). One of the things a clip must show is the Disk tool scanning: press Scan, watch a spinner and the text "Working...", then see the results.

In the sample data mode used for recording, a scan is a `Task` that waits and then fills in fictional results. This is the real code from `DiskOverviewView.swift`:

```swift
// Sources/NotchStar/Tools/Disk/DiskOverviewView.swift, line 28
Task { await DemoMode.pause(1.6); groups = DemoMode.diskGroups; selected = []; scanning = false; scanned = true }
```

And `DemoMode.pause` is just a sleep:

```swift
// Sources/NotchStar/Demo/DemoMode.swift
static func pause(_ seconds: Double) async { try? await Task.sleep(for: .seconds(seconds)) }
```

The recorded video showed the spinner, and then it kept showing the spinner. The time never came when `scanning` flipped to `false`. The code was fine on its own. The `Task` had been created and never got to run during the part of the script that mattered.

The same pattern appears in six Disk views. A search of `Sources` for `DemoMode.pause` on 4 November 2026 lists 9 call sites across `CompressView`, `DiskOverviewView`, `FindView`, `SpaceView`, `DuplicatesView` and `AppsView`, all inside `Task` closures.

## The first design and the wrong turn

The recorder needs a clock. A clip is real time: spring animations, hover states and typing all happen as the script runs, and a frame is captured about 30 times a second. The obvious way to write that in an XCTest is a synchronous script that moves the cursor, then waits, then clicks, with the wait being a pump of the run loop so the UI can update:

```swift
RunLoop.main.run(until: Date().addingTimeInterval(0.4))    // let the views build
```

That line still exists, once, in `ClipRecorder.init`. It was the first idea for every wait, and it is the same call the still image render tests use (10 call sites in `Tests/NotchStarTests` on 4 November 2026). For a still render it is fine: set up a view, pump for a moment, call `cacheDisplay`, save a PNG.

The earlier synchronous version of the recorder is not in the git history. The first commit that contains `ClipRecorder.swift`, on 7 October 2026, already has the `async` form. What the project notes record is the symptom and the fix: a Swift `Task` never ran while the script pumped the run loop, because the test body occupied the main actor, and the demo scans stayed on "Working..." forever.

Still renders avoid the problem by construction. The same Disk view, when `DemoMode.live` is false, fills its sample data synchronously in its initializer:

```swift
// DiskOverviewView.swift, line 18
if !DemoMode.live { groups = DemoMode.diskGroups; selected = [.developer, .caches]; scanned = true }
```

So a still never needs a `Task` to finish, and a pump of the run loop is enough. Only live clips, where the scan has to take a visible moment, start a `Task`. That is why the bug showed up in recordings and not in the screenshots.

## Why a blocked main actor starves a Task

Swift's main actor is a serial executor. It runs one job at a time, and a job runs until it finishes or reaches an `await` that suspends. A `Task { ... }` created from main actor code is itself a main actor job that waits in line.

A test method annotated with `@MainActor` is one such job. If it never suspends, nothing queued behind it can start. Pumping `RunLoop` inside it does not count as suspending: it is an ordinary blocking call that happens to service run loop sources while it blocks.

The smallest honest demonstration is a busy wait. This is a standalone program, compiled with `swiftc`, and not the XCTest host:

```swift
@MainActor var ran = false

@MainActor func busy() {
    Task { ran = true }
    let end = Date().addingTimeInterval(1.0)
    while Date() < end { }       // occupies the main actor
    print("busy wait 1 s: ran =", ran)
}
```

Run on 4 November 2026 (macOS 27.0.1, Swift 6.4, `swiftc -parse-as-library`), it prints `busy wait 1 s: ran = false`. After one `await Task.sleep(for: .milliseconds(10))` the flag is `true`. The waiting `Task` runs as soon as the current job suspends.

> **Lesson:** `await` is the only wait that tells the main actor it may run something else. A blocking wait, however long and however many run loop turns it makes, is a decision to keep the actor to yourself.
## A repro that did not fail

The tidy story would end here. It does not, because a second experiment behaved differently. The same standalone program, with the busy loop replaced by `RunLoop.main.run(until:)` for one second:

```swift
@MainActor func blocking() {
    Task { ran = true }
    RunLoop.main.run(until: Date().addingTimeInterval(1.0))
    print("after RunLoop.run for 1 s: ran =", ran)
}
```

printed `ran = true`, both when called from inside a main actor job and when called from a synchronous `MainActor.assumeIsolated` block. A second `Task` that looped on `Task.sleep(for: .milliseconds(100))` counted 9 ticks during the one second pump. In a small command line program on this Mac, `RunLoop.run` let Swift tasks proceed.

What that means for the post is narrow and worth stating plainly:

| Claim | Status |
|---|---|
| In the recording script, demo scans stayed on "Working..." while the script pumped the run loop | Recorded symptom in the project notes |
| Making the recorder `async` and awaiting `Task.sleep` fixed it | Verified: this is the code that records the 13 clips |
| A busy wait on the main actor starves a waiting `Task` | Reproduced on 4 November 2026 |
| `RunLoop.main.run(until:)` always starves a waiting `Task` | Not reproduced in a standalone program. Do not rely on this sentence being true |

The failure in the test host may depend on how XCTest drives the test body, on the Swift or macOS version of the time, or on something in the script that the notes do not capture. The conclusion that survives is the practical one: in a script that depends on tasks making progress, a blocking pause is a gamble, and `await Task.sleep` is not.

## The fix: an async recorder paced by Task.sleep

The recorder's central method is `run`. It captures frames for a duration and calls an update closure first, so cursor motion and typing happen between frames:

```swift
// Tests/NotchStarTests/Video/ClipRecorder.swift
func run(_ duration: TimeInterval, update: ((Double) -> Void)? = nil) async {
    let start = now
    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)
        // Awaiting (not blocking) lets the main actor run other tasks, like the demo's simulated scans.
        try? await Task.sleep(for: .seconds(max(0.001, nextFrameAt - now)))
    }
}
```

Three details carry the design:

- The frame deadline is `nextFrameAt + 1 / fps`, clamped to `now`. A slow frame moves the schedule forward instead of causing a burst of catch up frames.
- The sleep is at least one millisecond, so the loop always suspends at least once per frame, even when it is behind. That guarantees other tasks a turn on every iteration.
- `frame()` itself is synchronous and runs on the main actor: layout, `cacheDisplay` and the pixel buffer write. Only the wait between frames is asynchronous.

Everything else is built on `run`. `wait(_:)` is `await run(seconds)`. A mouse move is `run` with an update closure that eases the cursor. A click, a drag, typing and scrolling all end up in the same loop. So the whole script API became `async`, and the clip tests became `async throws`:

```swift
func testClipboard() async throws {
    let rec = try await recorder("clipboard", tool: "clipboard", glow: ClipRecorder.glowTeal)
    await rec.wait(1.0)
    await rec.click("clip.search", settle: 0.3)
    await rec.type("meet", charactersPerSecond: 6); await rec.wait(1.2)
    // ...
    await finish(rec, "clipboard")
}
```

`ClipScripts.swift` has 13 `func test` methods and 106 lines that start an `await rec.` call (counted with `grep -c` on 4 November 2026). XCTest supports `async` test methods on the main actor, so no extra plumbing was needed.

## The same pattern in a normal test

The unit test for the screenshot watcher shows the rule outside the recorder. The watcher runs its own polling `Task`, so the test has to let that task run, and it does so by awaiting:

```swift
func testWatcherIgnoresExistingFilesAndSticksANewCapture() async throws {
    // ...
    watcher.start()
    try await Task.sleep(for: .milliseconds(500))
    XCTAssertTrue(tray.items.isEmpty)
    try Data(repeating: 2, count: 64).write(to: dir.appendingPathComponent("Screenshot new.png"))
    try await Task.sleep(for: .milliseconds(900))
    XCTAssertEqual(tray.items.map(\.url.lastPathComponent), ["Screenshot new.png"])
}
```

The clipboard tests go the other way: `ClipboardCaptureTests` never waits for the polling task at all. It calls `tool.check()` directly. Separating "look once" from "look every half second" made the logic testable without any timing. That is the second lesson, and it applies to anything that polls.

## Numbers and method

| Measure | Value | Method |
|---|---|---|
| Frames per second the recorder aims for | 30 | `let fps = 30.0` in `ClipRecorder.swift` |
| Time to capture one frame | about 23 ms | Recorded in the project facts file for the recorder; not measured again for this post |
| Shortest sleep between frames | 1 ms | `max(0.001, ...)` in `run` |
| `RunLoop.main.run` call sites in `Tests/NotchStarTests` | 10 | `grep -rn "RunLoop.main.run" Tests`, 4 November 2026 |
| Clip tests | 13 | `grep -c "func test" Tests/NotchStarTests/Video/ClipScripts.swift`, 4 November 2026 |
| Script lines awaiting the recorder | 106 | `grep -n "await rec\." Tests/NotchStarTests/Video/ClipScripts.swift \| wc -l`, 4 November 2026 |
| Standalone busy wait of 1 s | Task did not run | `swiftc` program, macOS 27.0.1, Swift 6.4, 4 November 2026 |
| Standalone `RunLoop.run` of 1 s | Task ran, 9 ticks of a 100 ms loop | same program family, same day |

Nothing here is a performance claim about the app. The 23 ms capture time and the 30 frame target describe the test recorder only.

## What is still bad

- The exact trigger in the test host is not reproduced. The notes say the main actor was occupied; a standalone check shows that a busy wait does that and that `RunLoop.run` alone does not.
- `ClipRecorder.init` still pumps the run loop for 0.4 seconds to let the views build. It works because nothing in the first 0.4 seconds needs a `Task`, which is an assumption and not a guarantee.
- Ten `RunLoop.main.run` pauses remain in the other render tests. They are fine for still images and would be wrong for anything that starts a `Task`.
- `async` recording makes timing depend on the scheduler. A busy Mac can drop a click. `ClipRecorder.tab` retries up to three times and checks that the tool really switched.
- The earlier broken script is not in git, so the before and after cannot be shown as a diff.

## Try it yourself

1. Write a `@MainActor` test that starts `Task { done = true }`, then waits with `RunLoop.main.run(until:)` for a second, then asserts `done`.
2. Run it. Note whether it passes in your host, your Xcode and your macOS version.
3. Replace the pump with `try await Task.sleep(for: .seconds(1))` and make the test `async`. Run it again.
4. Replace the pump with a `while Date() < end { }` loop. This one should fail whatever the host is.
5. If you write a recorder or any scripted driver, pace it with a deadline per frame and a sleep of at least a millisecond.

## Cheat sheet

| Situation | Do this | Avoid this |
|---|---|---|
| A script needs `Task`s to make progress | `await Task.sleep` between steps | Blocking waits of any kind |
| A test checks logic that polls | Expose the single check (`check()`) and call it | Sleeping for the poll interval |
| A still render needs sample data | Fill it synchronously in `init` | Starting a `Task` to do it |
| A frame loop falls behind | Move the deadline forward, keep a 1 ms sleep | Looping without suspending |
| A pump of the run loop is unavoidable | Keep it short and put it where no `Task` is needed | Using it as the script clock |

## Close

NotchStar is a macOS app that puts ten small tools in the notch, and these clips are how the site shows them. If you want to try it, the details are at [notchstar.com](https://www.notchstar.com).

## More posts

- [NSImage(contentsOfFile:) blocked for 327 seconds: reading a wallpaper while Spotlight indexes](https://www.notchstar.com/blog/nsimage-contentsoffile-blocked-327-seconds/)
- [ProgressView did not redraw offscreen: drawing your own thin bar in SwiftUI](https://www.notchstar.com/blog/swiftui-progressview-offscreen-thinbar/)
