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

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.

By Arin Prajapati 9 min read
Black cover with the NotchStar logo, the kicker Swift concurrency and the headline A Task that never ran

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:

// 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:

// 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:

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:

// 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:

@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.

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:

@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:

ClaimStatus
In the recording script, demo scans stayed on “Working…” while the script pumped the run loopRecorded symptom in the project notes
Making the recorder async and awaiting Task.sleep fixed itVerified: this is the code that records the 13 clips
A busy wait on the main actor starves a waiting TaskReproduced on 4 November 2026
RunLoop.main.run(until:) always starves a waiting TaskNot 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:

// 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:

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:

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

MeasureValueMethod
Frames per second the recorder aims for30let fps = 30.0 in ClipRecorder.swift
Time to capture one frameabout 23 msRecorded in the project facts file for the recorder; not measured again for this post
Shortest sleep between frames1 msmax(0.001, ...) in run
RunLoop.main.run call sites in Tests/NotchStarTests10grep -rn "RunLoop.main.run" Tests, 4 November 2026
Clip tests13grep -c "func test" Tests/NotchStarTests/Video/ClipScripts.swift, 4 November 2026
Script lines awaiting the recorder106grep -n "await rec\." Tests/NotchStarTests/Video/ClipScripts.swift | wc -l, 4 November 2026
Standalone busy wait of 1 sTask did not runswiftc program, macOS 27.0.1, Swift 6.4, 4 November 2026
Standalone RunLoop.run of 1 sTask ran, 9 ticks of a 100 ms loopsame 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

SituationDo thisAvoid this
A script needs Tasks to make progressawait Task.sleep between stepsBlocking waits of any kind
A test checks logic that pollsExpose the single check (check()) and call itSleeping for the poll interval
A still render needs sample dataFill it synchronously in initStarting a Task to do it
A frame loop falls behindMove the deadline forward, keep a 1 ms sleepLooping without suspending
A pump of the run loop is unavoidableKeep it short and put it where no Task is neededUsing 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.

NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.