diff --git a/Sources/JavaScriptEventLoop/JavaScriptEventLoop+YieldInterval.swift b/Sources/JavaScriptEventLoop/JavaScriptEventLoop+YieldInterval.swift new file mode 100644 index 000000000..dbcd30b9f --- /dev/null +++ b/Sources/JavaScriptEventLoop/JavaScriptEventLoop+YieldInterval.swift @@ -0,0 +1,103 @@ +// 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 drainElapsed(since start: DrainInstant) -> Duration { + .milliseconds(drainClockNow() - start) + } + #else + typealias DrainInstant = ContinuousClock.Instant + + func drainClockNow() -> DrainInstant { + ContinuousClock.now + } + + func drainElapsed(since start: DrainInstant) -> Duration { + start.duration(to: .now) + } + #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 diff --git a/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift b/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift index ead6157bf..ffca27795 100644 --- a/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift +++ b/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift @@ -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() diff --git a/Sources/JavaScriptEventLoop/JobQueue.swift b/Sources/JavaScriptEventLoop/JobQueue.swift index a0f2c4bbb..0dcbcdc17 100644 --- a/Sources/JavaScriptEventLoop/JobQueue.swift +++ b/Sources/JavaScriptEventLoop/JobQueue.swift @@ -43,12 +43,37 @@ extension JavaScriptEventLoop { func runAllJobs() { assert(queueState.isSpinning) + // The clock is only read while `yieldInterval` is set. + var start: DrainInstant? = yieldInterval == nil ? nil : drainClockNow() + // A clock read costs more than a small job. Read it every `clockStride` jobs, and double + // the stride (up to 256) while the drain is far from the interval. + var clockStride = 1 + var jobsUntilClockRead = 1 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 { + jobsUntilClockRead -= 1 + if jobsUntilClockRead > 0 { continue } + let elapsed = drainElapsed(since: start) + if elapsed >= yieldInterval { + // Keep `isSpinning` set so new jobs wait for the continuation. + queueHostTask { self.runAllJobs() } + return + } + if elapsed * 4 < yieldInterval { + clockStride = min(clockStride * 2, 256) + } + jobsUntilClockRead = clockStride + } else { + start = drainClockNow() + } + } } queueState.isSpinning = false diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/JavaScript-Interop-Cheat-Sheet.md b/Sources/JavaScriptKit/Documentation.docc/Articles/JavaScript-Interop-Cheat-Sheet.md index 4280a4637..b554e5a7b 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/JavaScript-Interop-Cheat-Sheet.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/JavaScript-Interop-Cheat-Sheet.md @@ -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. diff --git a/Tests/JavaScriptEventLoopTests/JavaScriptEventLoopYieldIntervalTests.swift b/Tests/JavaScriptEventLoopTests/JavaScriptEventLoopYieldIntervalTests.swift new file mode 100644 index 000000000..4269b89b2 --- /dev/null +++ b/Tests/JavaScriptEventLoopTests/JavaScriptEventLoopYieldIntervalTests.swift @@ -0,0 +1,79 @@ +import JavaScriptEventLoop +import JavaScriptKit +import XCTest + +final class JavaScriptEventLoopYieldIntervalTests: XCTestCase { + final class Box: @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..