Skip to content

Commit 7bb8dfd

Browse files
iamdadzillaclaude
andcommitted
Fix: poll sleep state off the main thread (menu bar icon didn't appear)
StateMonitor ran the synchronous XPC read on the main thread (initial poll in applicationDidFinishLaunching + a main-run-loop timer). When the helper is unreachable (e.g. a fresh install before the Login Items approval), each read blocked the main thread for the full timeout, starving the run loop so SwiftUI never installed the MenuBarExtra — the process ran with no menu-bar icon. poll() now runs read() on a background queue and delivers onObserved back on main, with an in-flight guard to coalesce overlapping ticks. Regression test proves poll() no longer blocks its caller. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0414016 commit 7bb8dfd

2 files changed

Lines changed: 38 additions & 6 deletions

File tree

Sources/MacsomniaCore/StateMonitor.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,28 @@ public final class StateMonitor {
77
private let read: () -> Bool?
88
private let onObserved: (Bool) -> Void
99
private var timer: Timer?
10+
private let workQueue = DispatchQueue(label: "com.macsomnia.statemonitor")
11+
private var inFlight = false // touched only on the main thread
1012

1113
public init(read: @escaping () -> Bool?, onObserved: @escaping (Bool) -> Void) {
1214
self.read = read
1315
self.onObserved = onObserved
1416
}
1517

18+
/// Call on the main thread. `read()` may block (synchronous XPC to the
19+
/// privileged helper), so it runs on a background queue and never stalls the
20+
/// caller's run loop — otherwise a slow/unreachable helper at launch would
21+
/// keep SwiftUI from ever installing the menu-bar item. `onObserved` is
22+
/// delivered back on the main thread. Overlapping ticks are coalesced.
1623
public func poll() {
17-
if let value = read() {
18-
onObserved(value)
24+
if inFlight { return }
25+
inFlight = true
26+
workQueue.async { [weak self] in
27+
let value = self?.read()
28+
DispatchQueue.main.async {
29+
self?.inFlight = false
30+
if let value { self?.onObserved(value) }
31+
}
1932
}
2033
}
2134

Tests/MacsomniaCoreTests/StateMonitorTests.swift

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,35 @@ import XCTest
33

44
final class StateMonitorTests: XCTestCase {
55
func testPollForwardsObservedValue() {
6+
let exp = expectation(description: "observed")
67
var observed: [Bool] = []
7-
let monitor = StateMonitor(read: { true }, onObserved: { observed.append($0) })
8+
let monitor = StateMonitor(read: { true }, onObserved: { observed.append($0); exp.fulfill() })
89
monitor.poll()
10+
wait(for: [exp], timeout: 2)
911
XCTAssertEqual(observed, [true])
1012
}
1113

1214
func testPollSkipsWhenReadReturnsNil() {
13-
var observed: [Bool] = []
14-
let monitor = StateMonitor(read: { nil }, onObserved: { observed.append($0) })
15+
let exp = expectation(description: "should not observe")
16+
exp.isInverted = true
17+
let monitor = StateMonitor(read: { nil }, onObserved: { _ in exp.fulfill() })
18+
monitor.poll()
19+
wait(for: [exp], timeout: 0.3)
20+
}
21+
22+
/// `read()` can block (synchronous XPC to the privileged helper). `poll()`
23+
/// must run it off the caller's thread — otherwise a slow/unreachable helper
24+
/// blocks the main run loop at launch and the menu-bar item never appears.
25+
func testPollDoesNotBlockCaller() {
26+
let exp = expectation(description: "observed")
27+
let monitor = StateMonitor(
28+
read: { Thread.sleep(forTimeInterval: 0.5); return true },
29+
onObserved: { _ in exp.fulfill() }
30+
)
31+
let start = Date()
1532
monitor.poll()
16-
XCTAssertTrue(observed.isEmpty)
33+
let elapsed = Date().timeIntervalSince(start)
34+
XCTAssertLessThan(elapsed, 0.1, "poll() must not block its caller while read() runs")
35+
wait(for: [exp], timeout: 2)
1736
}
1837
}

0 commit comments

Comments
 (0)