Skip to content

Commit dbbfdc3

Browse files
authored
Merge pull request swiftwasm#821 from PassiveLogic/kr/event-loop-yield-interval
JavaScriptEventLoop: Add opt-in `yieldInterval` for host responsiveness
2 parents 94388fa + edf0906 commit dbbfdc3

5 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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 drainElapsed(since start: DrainInstant) -> Duration {
24+
.milliseconds(drainClockNow() - start)
25+
}
26+
#else
27+
typealias DrainInstant = ContinuousClock.Instant
28+
29+
func drainClockNow() -> DrainInstant {
30+
ContinuousClock.now
31+
}
32+
33+
func drainElapsed(since start: DrainInstant) -> Duration {
34+
start.duration(to: .now)
35+
}
36+
#endif
37+
38+
/// Runs `body` in a new JavaScript task via `setImmediate`, `MessageChannel`, or `setTimeout`.
39+
func queueHostTask(_ body: @escaping () -> Void) {
40+
if hostTaskScheduler == nil {
41+
hostTaskScheduler = Self.makeHostTaskScheduler()
42+
}
43+
hostTaskScheduler!(body)
44+
}
45+
46+
private static func makeHostTaskScheduler() -> (@escaping () -> Void) -> Void {
47+
let global = JSObject.global
48+
if let setImmediate = global.setImmediate.function {
49+
return { body in
50+
_ = setImmediate(
51+
JSOneshotClosure { _ in
52+
body()
53+
return .undefined
54+
}
55+
)
56+
}
57+
}
58+
if let messageChannel = global.MessageChannel.function {
59+
// Unlike nested `setTimeout` calls, messages are not clamped to 4 ms.
60+
let channel = messageChannel.new(arguments: [JSValue]())
61+
let sender = channel.port1.object!
62+
let postMessage = sender.postMessage.function!
63+
let pending = PendingHostTasks()
64+
let receiver = JSClosure { _ in
65+
pending.runAll()
66+
return .undefined
67+
}
68+
channel.port2.object!.onmessage = .object(receiver)
69+
return { body in
70+
pending.bodies.append(body)
71+
// Keep `receiver` alive as long as the scheduler.
72+
withExtendedLifetime(receiver) {
73+
_ = postMessage(this: sender, JSValue.undefined)
74+
}
75+
}
76+
}
77+
let setTimeout = global.setTimeout.function!
78+
return { body in
79+
_ = setTimeout(
80+
JSOneshotClosure { _ in
81+
body()
82+
return .undefined
83+
},
84+
0
85+
)
86+
}
87+
}
88+
}
89+
90+
/// Bodies waiting for the next `MessageChannel` message.
91+
private final class PendingHostTasks {
92+
var bodies: [() -> Void] = []
93+
94+
func runAll() {
95+
let bodies = self.bodies
96+
self.bodies = []
97+
for body in bodies {
98+
body()
99+
}
100+
}
101+
}
102+
103+
#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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,37 @@ 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()
48+
// A clock read costs more than a small job. Read it every `clockStride` jobs, and double
49+
// the stride (up to 256) while the drain is far from the interval.
50+
var clockStride = 1
51+
var jobsUntilClockRead = 1
4652
while let job = self.claimNextFromQueue() {
4753
#if compiler(>=5.9)
4854
job.runSynchronously(on: self.asUnownedSerialExecutor())
4955
#else
5056
job._runSynchronously(on: self.asUnownedSerialExecutor())
5157
#endif
58+
59+
if let yieldInterval, queueState.headJob != nil {
60+
if let start {
61+
jobsUntilClockRead -= 1
62+
if jobsUntilClockRead > 0 { continue }
63+
let elapsed = drainElapsed(since: start)
64+
if elapsed >= yieldInterval {
65+
// Keep `isSpinning` set so new jobs wait for the continuation.
66+
queueHostTask { self.runAllJobs() }
67+
return
68+
}
69+
if elapsed * 4 < yieldInterval {
70+
clockStride = min(clockStride * 2, 256)
71+
}
72+
jobsUntilClockRead = clockStride
73+
} else {
74+
start = drainClockNow()
75+
}
76+
}
5277
}
5378

5479
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)