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.
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 withRunLoop.run, a call that blocks. The fix was to make the recorderasyncand wait withawait 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
Taskno turn. A busy wait reproduces that every time. A plainRunLoop.runcall 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 nowawaiteach 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:
| 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:
// 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 tonow. 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,cacheDisplayand 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
| 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.runalone does not. ClipRecorder.initstill pumps the run loop for 0.4 seconds to let the views build. It works because nothing in the first 0.4 seconds needs aTask, which is an assumption and not a guarantee.- Ten
RunLoop.main.runpauses remain in the other render tests. They are fine for still images and would be wrong for anything that starts aTask. asyncrecording makes timing depend on the scheduler. A busy Mac can drop a click.ClipRecorder.tabretries 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
- Write a
@MainActortest that startsTask { done = true }, then waits withRunLoop.main.run(until:)for a second, then assertsdone. - Run it. Note whether it passes in your host, your Xcode and your macOS version.
- Replace the pump with
try await Task.sleep(for: .seconds(1))and make the testasync. Run it again. - Replace the pump with a
while Date() < end { }loop. This one should fail whatever the host is. - 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 Tasks 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.
NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.