Skip to content

Commit 0b3eb04

Browse files
committed
JavaScriptEventLoop: Add opt-in yieldInterval for host responsiveness
1 parent 777848d commit 0b3eb04

5 files changed

Lines changed: 219 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Clock and host task scheduling for `JavaScriptEventLoop.yieldInterval`.
2+
3+
import JavaScriptKit
4+
import _Concurrency
5+
6+
#if compiler(>=5.5)
7+
8+
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
9+
extension JavaScriptEventLoop {
10+
11+
#if hasFeature(Embedded)
12+
// `ContinuousClock` is unavailable in Embedded Swift; use `performance.now()` in milliseconds.
13+
typealias DrainInstant = Double
14+
15+
func drainClockNow() -> DrainInstant {
16+
if performanceNow == nil {
17+
let performance = JSObject.global.performance.object!
18+
performanceNow = (performance, performance.now.function!)
19+
}
20+
return performanceNow!.now(this: performanceNow!.performance).number!
21+
}
22+
23+
func hasRun(since start: DrainInstant, for interval: Duration) -> Bool {
24+
let (seconds, attoseconds) = interval.components
25+
let intervalMilliseconds = Double(seconds) * 1_000 + Double(attoseconds) / 1_000_000_000_000_000
26+
return drainClockNow() - start >= intervalMilliseconds
27+
}
28+
#else
29+
typealias DrainInstant = ContinuousClock.Instant
30+
31+
func drainClockNow() -> DrainInstant {
32+
ContinuousClock.now
33+
}
34+
35+
func hasRun(since start: DrainInstant, for interval: Duration) -> Bool {
36+
start.duration(to: .now) >= interval
37+
}
38+
#endif
39+
40+
/// Runs `body` in a new JavaScript task via `setImmediate`, `MessageChannel`, or `setTimeout`.
41+
func queueHostTask(_ body: @escaping () -> Void) {
42+
if hostTaskScheduler == nil {
43+
hostTaskScheduler = Self.makeHostTaskScheduler()
44+
}
45+
hostTaskScheduler!(body)
46+
}
47+
48+
private static func makeHostTaskScheduler() -> (@escaping () -> Void) -> Void {
49+
let global = JSObject.global
50+
if let setImmediate = global.setImmediate.function {
51+
return { body in
52+
_ = setImmediate(
53+
JSOneshotClosure { _ in
54+
body()
55+
return .undefined
56+
}
57+
)
58+
}
59+
}
60+
if let messageChannel = global.MessageChannel.function {
61+
// Unlike nested `setTimeout` calls, messages are not clamped to 4 ms.
62+
let channel = messageChannel.new(arguments: [JSValue]())
63+
let sender = channel.port1.object!
64+
let postMessage = sender.postMessage.function!
65+
let pending = PendingHostTasks()
66+
let receiver = JSClosure { _ in
67+
pending.runAll()
68+
return .undefined
69+
}
70+
channel.port2.object!.onmessage = .object(receiver)
71+
return { body in
72+
pending.bodies.append(body)
73+
// Keep `receiver` alive as long as the scheduler.
74+
withExtendedLifetime(receiver) {
75+
_ = postMessage(this: sender, JSValue.undefined)
76+
}
77+
}
78+
}
79+
let setTimeout = global.setTimeout.function!
80+
return { body in
81+
_ = setTimeout(
82+
JSOneshotClosure { _ in
83+
body()
84+
return .undefined
85+
},
86+
0
87+
)
88+
}
89+
}
90+
}
91+
92+
/// Bodies waiting for the next `MessageChannel` message.
93+
private final class PendingHostTasks {
94+
var bodies: [() -> Void] = []
95+
96+
func runAll() {
97+
let bodies = self.bodies
98+
self.bodies = []
99+
for body in bodies {
100+
body()
101+
}
102+
}
103+
}
104+
105+
#endif

‎Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,26 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable {
4848
/// A function that invokes a given closure after a specified number of milliseconds.
4949
public var setTimeout: (Double, @escaping () -> Void) -> Void
5050

51+
/// The maximum time to run queued jobs before yielding to the JavaScript event loop.
52+
///
53+
/// By default (`nil`), all queued jobs run in a single microtask, so long-running async work
54+
/// blocks rendering, input, and other JavaScript tasks until it completes. When set, the
55+
/// remaining jobs continue in a new JavaScript task after the interval, at the cost of some
56+
/// latency. A single job is never interrupted.
57+
///
58+
/// ```swift
59+
/// JavaScriptEventLoop.shared.yieldInterval = .milliseconds(16)
60+
/// ```
61+
public var yieldInterval: Duration?
62+
63+
/// Continues a drain that stopped at ``yieldInterval``. Created lazily.
64+
var hostTaskScheduler: ((@escaping () -> Void) -> Void)?
65+
66+
#if hasFeature(Embedded)
67+
/// Cached `performance.now` used by ``yieldInterval`` in Embedded Swift.
68+
var performanceNow: (performance: JSObject, now: JSFunction)?
69+
#endif
70+
5171
/// A mutable state to manage internal job queue
5272
/// Note that this should be guarded atomically when supporting multi-threaded environment.
5373
var queueState = QueueState()

‎Sources/JavaScriptEventLoop/JobQueue.swift‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,26 @@ extension JavaScriptEventLoop {
4343
func runAllJobs() {
4444
assert(queueState.isSpinning)
4545

46+
// The clock is only read while `yieldInterval` is set.
47+
var start: DrainInstant? = yieldInterval == nil ? nil : drainClockNow()
4648
while let job = self.claimNextFromQueue() {
4749
#if compiler(>=5.9)
4850
job.runSynchronously(on: self.asUnownedSerialExecutor())
4951
#else
5052
job._runSynchronously(on: self.asUnownedSerialExecutor())
5153
#endif
54+
55+
if let yieldInterval, queueState.headJob != nil {
56+
if let start {
57+
if hasRun(since: start, for: yieldInterval) {
58+
// Keep `isSpinning` set so new jobs wait for the continuation.
59+
queueHostTask { self.runAllJobs() }
60+
return
61+
}
62+
} else {
63+
start = drainClockNow()
64+
}
65+
}
5266
}
5367

5468
queueState.isSpinning = false

‎Sources/JavaScriptKit/Documentation.docc/Articles/JavaScript-Interop-Cheat-Sheet.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,5 @@ let swiftPromise = JSPromise.async {
373373
- Use `JSPromise.async(body:)` (with `Void` or `JSValue` return type) to expose Swift `async/await` work to JavaScript callers.
374374
- To await JavaScript `Promise` from Swift, import `JavaScriptEventLoop`, call `JavaScriptEventLoop.installGlobalExecutor()` early, and use the `value` property.
375375
- The `value` property suspends until the promise resolves or rejects, rethrowing rejections as ``JSException``.
376+
- Long-running async work blocks rendering and input until it completes. Set `JavaScriptEventLoop.shared.yieldInterval` (for example, `.milliseconds(16)`) to yield to the JavaScript event loop between jobs.
376377

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import JavaScriptEventLoop
2+
import JavaScriptKit
3+
import XCTest
4+
5+
final class JavaScriptEventLoopYieldIntervalTests: XCTestCase {
6+
final class Box<Value>: @unchecked Sendable {
7+
var value: Value
8+
init(_ value: Value) { self.value = value }
9+
}
10+
11+
override func tearDown() {
12+
JavaScriptEventLoop.shared.yieldInterval = nil
13+
super.tearDown()
14+
}
15+
16+
static func busyWait(milliseconds: Int) {
17+
let end = ContinuousClock.now + .milliseconds(milliseconds)
18+
while ContinuousClock.now < end {}
19+
}
20+
21+
/// Returns, for each busy task, whether a JavaScript timer scheduled before them had fired.
22+
func timerRanAfterEachTask(count: Int, milliseconds: Int) async -> [Bool] {
23+
let timerRan = Box(false)
24+
_ = JSObject.global.setTimeout!(
25+
JSOneshotClosure { _ in
26+
timerRan.value = true
27+
return .undefined
28+
},
29+
0
30+
)
31+
let observed = Box([Bool]())
32+
let tasks = (0..<count).map { _ in
33+
Task {
34+
Self.busyWait(milliseconds: milliseconds)
35+
observed.value.append(timerRan.value)
36+
}
37+
}
38+
for task in tasks { await task.value }
39+
return observed.value
40+
}
41+
42+
func testYieldIntervalIsNilByDefault() {
43+
XCTAssertNil(JavaScriptEventLoop.shared.yieldInterval)
44+
}
45+
46+
func testJobsRunInOneDrainWithoutYieldInterval() async {
47+
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
48+
XCTAssertEqual(observed, Array(repeating: false, count: 20))
49+
}
50+
51+
func testJavaScriptTasksRunBetweenJobsWithYieldInterval() async {
52+
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(5)
53+
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
54+
XCTAssertEqual(observed.first, false)
55+
XCTAssertEqual(observed.last, true)
56+
}
57+
58+
func testYieldIntervalAppliesToALaterDrain() async throws {
59+
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(5)
60+
// Start a new drain.
61+
try await Task.sleep(nanoseconds: 1_000_000)
62+
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
63+
XCTAssertEqual(observed.first, false)
64+
XCTAssertEqual(observed.last, true)
65+
}
66+
67+
func testJobsKeepTheirOrderAcrossYields() async {
68+
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(1)
69+
let order = Box([Int]())
70+
let tasks = (0..<20).map { index in
71+
Task {
72+
Self.busyWait(milliseconds: 1)
73+
order.value.append(index)
74+
}
75+
}
76+
for task in tasks { await task.value }
77+
XCTAssertEqual(order.value, Array(0..<20))
78+
}
79+
}

0 commit comments

Comments
 (0)