Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions Sources/JavaScriptEventLoop/JavaScriptEventLoop+YieldInterval.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Clock and host task scheduling for `JavaScriptEventLoop.yieldInterval`.

import JavaScriptKit
import _Concurrency

#if compiler(>=5.5)

@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
extension JavaScriptEventLoop {

#if hasFeature(Embedded)
// `ContinuousClock` is unavailable in Embedded Swift; use `performance.now()` in milliseconds.
typealias DrainInstant = Double

func drainClockNow() -> DrainInstant {
if performanceNow == nil {
let performance = JSObject.global.performance.object!
performanceNow = (performance, performance.now.function!)
}
return performanceNow!.now(this: performanceNow!.performance).number!
}

func hasRun(since start: DrainInstant, for interval: Duration) -> Bool {
let (seconds, attoseconds) = interval.components
let intervalMilliseconds = Double(seconds) * 1_000 + Double(attoseconds) / 1_000_000_000_000_000
return drainClockNow() - start >= intervalMilliseconds
}
#else
typealias DrainInstant = ContinuousClock.Instant

func drainClockNow() -> DrainInstant {
ContinuousClock.now
}

func hasRun(since start: DrainInstant, for interval: Duration) -> Bool {
start.duration(to: .now) >= interval
}
#endif

/// Runs `body` in a new JavaScript task via `setImmediate`, `MessageChannel`, or `setTimeout`.
func queueHostTask(_ body: @escaping () -> Void) {
if hostTaskScheduler == nil {
hostTaskScheduler = Self.makeHostTaskScheduler()
}
hostTaskScheduler!(body)
}

private static func makeHostTaskScheduler() -> (@escaping () -> Void) -> Void {
let global = JSObject.global
if let setImmediate = global.setImmediate.function {
return { body in
_ = setImmediate(
JSOneshotClosure { _ in
body()
return .undefined
}
)
}
}
if let messageChannel = global.MessageChannel.function {
// Unlike nested `setTimeout` calls, messages are not clamped to 4 ms.
let channel = messageChannel.new(arguments: [JSValue]())
let sender = channel.port1.object!
let postMessage = sender.postMessage.function!
let pending = PendingHostTasks()
let receiver = JSClosure { _ in
pending.runAll()
return .undefined
}
channel.port2.object!.onmessage = .object(receiver)
return { body in
pending.bodies.append(body)
// Keep `receiver` alive as long as the scheduler.
withExtendedLifetime(receiver) {
_ = postMessage(this: sender, JSValue.undefined)
}
}
}
let setTimeout = global.setTimeout.function!
return { body in
_ = setTimeout(
JSOneshotClosure { _ in
body()
return .undefined
},
0
)
}
}
}

/// Bodies waiting for the next `MessageChannel` message.
private final class PendingHostTasks {
var bodies: [() -> Void] = []

func runAll() {
let bodies = self.bodies
self.bodies = []
for body in bodies {
body()
}
}
}

#endif
20 changes: 20 additions & 0 deletions Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable {
/// A function that invokes a given closure after a specified number of milliseconds.
public var setTimeout: (Double, @escaping () -> Void) -> Void

/// The maximum time to run queued jobs before yielding to the JavaScript event loop.
///
/// By default (`nil`), all queued jobs run in a single microtask, so long-running async work
/// blocks rendering, input, and other JavaScript tasks until it completes. When set, the
/// remaining jobs continue in a new JavaScript task after the interval, at the cost of some
/// latency. A single job is never interrupted.
///
/// ```swift
/// JavaScriptEventLoop.shared.yieldInterval = .milliseconds(16)
/// ```
public var yieldInterval: Duration?

/// Continues a drain that stopped at ``yieldInterval``. Created lazily.
var hostTaskScheduler: ((@escaping () -> Void) -> Void)?

#if hasFeature(Embedded)
/// Cached `performance.now` used by ``yieldInterval`` in Embedded Swift.
var performanceNow: (performance: JSObject, now: JSFunction)?
#endif

/// A mutable state to manage internal job queue
/// Note that this should be guarded atomically when supporting multi-threaded environment.
var queueState = QueueState()
Expand Down
14 changes: 14 additions & 0 deletions Sources/JavaScriptEventLoop/JobQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,26 @@ extension JavaScriptEventLoop {
func runAllJobs() {
assert(queueState.isSpinning)

// The clock is only read while `yieldInterval` is set.
var start: DrainInstant? = yieldInterval == nil ? nil : drainClockNow()
while let job = self.claimNextFromQueue() {
#if compiler(>=5.9)
job.runSynchronously(on: self.asUnownedSerialExecutor())
#else
job._runSynchronously(on: self.asUnownedSerialExecutor())
#endif

if let yieldInterval, queueState.headJob != nil {
if let start {
if hasRun(since: start, for: yieldInterval) {
// Keep `isSpinning` set so new jobs wait for the continuation.
queueHostTask { self.runAllJobs() }
return
}
} else {
start = drainClockNow()
}
}
}

queueState.isSpinning = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,4 +373,5 @@ let swiftPromise = JSPromise.async {
- Use `JSPromise.async(body:)` (with `Void` or `JSValue` return type) to expose Swift `async/await` work to JavaScript callers.
- To await JavaScript `Promise` from Swift, import `JavaScriptEventLoop`, call `JavaScriptEventLoop.installGlobalExecutor()` early, and use the `value` property.
- The `value` property suspends until the promise resolves or rejects, rethrowing rejections as ``JSException``.
- 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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import JavaScriptEventLoop
import JavaScriptKit
import XCTest

final class JavaScriptEventLoopYieldIntervalTests: XCTestCase {
final class Box<Value>: @unchecked Sendable {
var value: Value
init(_ value: Value) { self.value = value }
}

override func tearDown() {
JavaScriptEventLoop.shared.yieldInterval = nil
super.tearDown()
}

static func busyWait(milliseconds: Int) {
let end = ContinuousClock.now + .milliseconds(milliseconds)
while ContinuousClock.now < end {}
}

/// Returns, for each busy task, whether a JavaScript timer scheduled before them had fired.
func timerRanAfterEachTask(count: Int, milliseconds: Int) async -> [Bool] {
let timerRan = Box(false)
_ = JSObject.global.setTimeout!(
JSOneshotClosure { _ in
timerRan.value = true
return .undefined
},
0
)
let observed = Box([Bool]())
let tasks = (0..<count).map { _ in
Task {
Self.busyWait(milliseconds: milliseconds)
observed.value.append(timerRan.value)
}
}
for task in tasks { await task.value }
return observed.value
}

func testYieldIntervalIsNilByDefault() {
XCTAssertNil(JavaScriptEventLoop.shared.yieldInterval)
}

func testJobsRunInOneDrainWithoutYieldInterval() async {
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
XCTAssertEqual(observed, Array(repeating: false, count: 20))
}

func testJavaScriptTasksRunBetweenJobsWithYieldInterval() async {
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(5)
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
XCTAssertEqual(observed.first, false)
XCTAssertEqual(observed.last, true)
}

func testYieldIntervalAppliesToALaterDrain() async throws {
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(5)
// Start a new drain.
try await Task.sleep(nanoseconds: 1_000_000)
let observed = await timerRanAfterEachTask(count: 20, milliseconds: 2)
XCTAssertEqual(observed.first, false)
XCTAssertEqual(observed.last, true)
}

func testJobsKeepTheirOrderAcrossYields() async {
JavaScriptEventLoop.shared.yieldInterval = .milliseconds(1)
let order = Box([Int]())
let tasks = (0..<20).map { index in
Task {
Self.busyWait(milliseconds: 1)
order.value.append(index)
}
}
for task in tasks { await task.value }
XCTAssertEqual(order.value, Array(0..<20))
}
}
Loading