Skip to content

Commit 9f7a9ea

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

5 files changed

Lines changed: 248 additions & 0 deletions

File tree

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

‎Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,45 @@ 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 longest time that this event loop runs queued Swift jobs before it lets the JavaScript
52+
/// event loop run other tasks, such as rendering, input events, timers and messages.
53+
///
54+
/// By default (`nil`), the event loop runs every queued job in one microtask, including jobs
55+
/// that those jobs enqueue. A long chain of Swift async work then blocks the JavaScript event
56+
/// loop until the chain is done. `Task.yield()` does not help, because the yielded task runs
57+
/// again in the same microtask.
58+
///
59+
/// When you set an interval, the event loop checks the time after each job. When the interval
60+
/// has passed and more jobs are queued, it runs the remaining jobs in a new JavaScript task
61+
/// (with `setImmediate`, a `MessageChannel` message or `setTimeout`, whichever is available).
62+
/// Jobs that are enqueued in the meantime run in that task, in priority order. A job is never
63+
/// interrupted, so a single job that runs longer than the interval still blocks the JavaScript
64+
/// event loop for its whole duration.
65+
///
66+
/// ```swift
67+
/// JavaScriptEventLoop.installGlobalExecutor()
68+
/// JavaScriptEventLoop.shared.yieldInterval = .milliseconds(16)
69+
/// ```
70+
///
71+
/// An interval trades latency for responsiveness. A job that is ready can wait while the
72+
/// JavaScript event loop runs other tasks, and JavaScript code can run between any two Swift
73+
/// jobs, not only while Swift code awaits JavaScript. A change takes effect after the current
74+
/// job. Delayed jobs, such as the ones of `Task.sleep`, are not affected.
75+
///
76+
/// In a multi-threaded environment, each thread has its own event loop. Set the interval on
77+
/// ``shared`` on the thread that it applies to.
78+
public var yieldInterval: Duration?
79+
80+
/// Schedules the rest of a drain of the job queue that stopped at ``yieldInterval``.
81+
/// Created on first use.
82+
var hostTaskScheduler: ((@escaping () -> Void) -> Void)?
83+
84+
#if hasFeature(Embedded)
85+
/// `performance` and its `now` method. ``yieldInterval`` uses them to measure time where
86+
/// `ContinuousClock` is unavailable. Created on first use.
87+
var performanceNow: (performance: JSObject, now: JSFunction)?
88+
#endif
89+
5190
/// A mutable state to manage internal job queue
5291
/// Note that this should be guarded atomically when supporting multi-threaded environment.
5392
var queueState = QueueState()

‎Sources/JavaScriptEventLoop/JobQueue.swift‎

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

46+
// The clock is read only while `yieldInterval` is set. A job can set it during this
47+
// drain; the time then counts from that job.
48+
var start: DrainInstant? = yieldInterval == nil ? nil : drainClockNow()
4649
while let job = self.claimNextFromQueue() {
4750
#if compiler(>=5.9)
4851
job.runSynchronously(on: self.asUnownedSerialExecutor())
4952
#else
5053
job._runSynchronously(on: self.asUnownedSerialExecutor())
5154
#endif
55+
56+
if let yieldInterval, queueState.headJob != nil {
57+
if let start {
58+
if hasRun(since: start, for: yieldInterval) {
59+
// Run the rest in a new JavaScript task. `isSpinning` stays true until
60+
// then, so jobs that are enqueued in between join the queue and do not
61+
// start another drain.
62+
queueHostTask { self.runAllJobs() }
63+
return
64+
}
65+
} else {
66+
start = drainClockNow()
67+
}
68+
}
5269
}
5370

5471
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+
- By default, Swift async work that is ready to run keeps running in one JavaScript task, so a long chain of jobs blocks rendering and input until it ends. Set `JavaScriptEventLoop.shared.yieldInterval` (for example, `.milliseconds(16)`) to let the JavaScript event loop run other tasks between Swift jobs.
376377

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
/// Spins for `milliseconds` without suspending.
17+
static func busyWait(milliseconds: Int) {
18+
let end = ContinuousClock.now + .milliseconds(milliseconds)
19+
while ContinuousClock.now < end {}
20+
}
21+
22+
/// Schedules a JavaScript timer, then runs `count` Swift tasks that each spin for
23+
/// `milliseconds`. Returns, for each task in order, whether the timer had run when the task ended.
24+
func timerRanAfterEachTask(count: Int, milliseconds: Int) async -> [Bool] {
25+
let timerRan = Box(false)
26+
_ = JSObject.global.setTimeout!(
27+
JSOneshotClosure { _ in
28+
timerRan.value = true
29+
return .undefined
30+
},
31+
0
32+
)
33+
let observed = Box([Bool]())
34+
let tasks = (0..<count).map { _ in
35+
Task {
36+
Self.busyWait(milliseconds: milliseconds)
37+
observed.value.append(timerRan.value)
38+
}
39+
}
40+
for task in tasks { await task.value }
41+
return observed.value
42+
}
43+
44+
func testYieldIntervalIsNilByDefault() {
45+
XCTAssertNil(JavaScriptEventLoop.shared.yieldInterval)
46+
}
47+
48+
func testJobsRunInOneDrainWithoutYieldInterval() async {
49+
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
50+
XCTAssertEqual(observed, Array(repeating: false, count: 20))
51+
}
52+
53+
func testJavaScriptTasksRunBetweenJobsWithYieldInterval() async {
54+
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(5)
55+
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
56+
XCTAssertEqual(observed.first, false)
57+
XCTAssertEqual(observed.last, true)
58+
}
59+
60+
func testYieldIntervalAppliesToALaterDrain() async throws {
61+
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(5)
62+
// End the current drain, so the work below runs in a new one.
63+
try await Task.sleep(nanoseconds: 1_000_000)
64+
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
65+
XCTAssertEqual(observed.first, false)
66+
XCTAssertEqual(observed.last, true)
67+
}
68+
69+
func testJobsKeepTheirOrderAcrossYields() async {
70+
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(1)
71+
let order = Box([Int]())
72+
let tasks = (0..<20).map { index in
73+
Task {
74+
Self.busyWait(milliseconds: 1)
75+
order.value.append(index)
76+
}
77+
}
78+
for task in tasks { await task.value }
79+
XCTAssertEqual(order.value, Array(0..<20))
80+
}
81+
}

0 commit comments

Comments
 (0)