Skip to content

Use the new ExecutorFactory protocol to provide a default executor #353

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 1, 2025
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
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ jobs:
target: "wasm32-unknown-wasi"
- os: ubuntu-22.04
toolchain:
download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a-ubuntu22.04.tar.gz
download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2025-04-12-a/swift-DEVELOPMENT-SNAPSHOT-2025-04-12-a-ubuntu22.04.tar.gz
wasi-backend: Node
target: "wasm32-unknown-wasi"
- os: ubuntu-22.04
toolchain:
download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a-ubuntu22.04.tar.gz
download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2025-04-12-a/swift-DEVELOPMENT-SNAPSHOT-2025-04-12-a-ubuntu22.04.tar.gz
wasi-backend: Node
target: "wasm32-unknown-wasip1-threads"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Implementation of custom executors for JavaScript event loop
// This file implements the ExecutorFactory protocol to provide custom main and global executors
// for Swift concurrency in JavaScript environment.
// See: https://github.com/swiftlang/swift/pull/80266
// See: https://forums.swift.org/t/pitch-2-custom-main-and-global-executors/78437

import _CJavaScriptKit

#if compiler(>=6.2)

// MARK: - MainExecutor Implementation
// MainExecutor is used by the main actor to execute tasks on the main thread
@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, visionOS 9999, *)
extension JavaScriptEventLoop: MainExecutor {
public func run() throws {
// This method is called from `swift_task_asyncMainDrainQueueImpl`.
// https://github.com/swiftlang/swift/blob/swift-DEVELOPMENT-SNAPSHOT-2025-04-12-a/stdlib/public/Concurrency/ExecutorImpl.swift#L28
// Yield control to the JavaScript event loop to skip the `exit(0)`
// call by `swift_task_asyncMainDrainQueueImpl`.
swjs_unsafe_event_loop_yield()
}
public func stop() {}
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
extension JavaScriptEventLoop: TaskExecutor {}

@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, visionOS 9999, *)
extension JavaScriptEventLoop: SchedulableExecutor {
public func enqueue<C: Clock>(
_ job: consuming ExecutorJob,
after delay: C.Duration,
tolerance: C.Duration?,
clock: C
) {
let milliseconds = Self.delayInMilliseconds(from: delay, clock: clock)
self.enqueue(
UnownedJob(job),
withDelay: milliseconds
)
}

private static func delayInMilliseconds<C: Clock>(from duration: C.Duration, clock: C) -> Double {
let swiftDuration = clock.convert(from: duration)!
let (seconds, attoseconds) = swiftDuration.components
return Double(seconds) * 1_000 + (Double(attoseconds) / 1_000_000_000_000_000)
}
}

// MARK: - ExecutorFactory Implementation
@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, visionOS 9999, *)
extension JavaScriptEventLoop: ExecutorFactory {
// Forward all operations to the current thread's JavaScriptEventLoop instance
final class CurrentThread: TaskExecutor, SchedulableExecutor, MainExecutor, SerialExecutor {
func checkIsolated() {}

func enqueue(_ job: consuming ExecutorJob) {
JavaScriptEventLoop.shared.enqueue(job)
}

func enqueue<C: Clock>(
_ job: consuming ExecutorJob,
after delay: C.Duration,
tolerance: C.Duration?,
clock: C
) {
JavaScriptEventLoop.shared.enqueue(
job,
after: delay,
tolerance: tolerance,
clock: clock
)
}
func run() throws {
try JavaScriptEventLoop.shared.run()
}
func stop() {
JavaScriptEventLoop.shared.stop()
}
}

public static var mainExecutor: any MainExecutor {
CurrentThread()
}

public static var defaultExecutor: any TaskExecutor {
CurrentThread()
}
}

#endif // compiler(>=6.2)
106 changes: 106 additions & 0 deletions Sources/JavaScriptEventLoop/JavaScriptEventLoop+LegacyHooks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import _CJavaScriptEventLoop
import _CJavaScriptKit

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

static func installByLegacyHook() {
#if compiler(>=5.9)
typealias swift_task_asyncMainDrainQueue_hook_Fn = @convention(thin) (
swift_task_asyncMainDrainQueue_original, swift_task_asyncMainDrainQueue_override
) -> Void
let swift_task_asyncMainDrainQueue_hook_impl: swift_task_asyncMainDrainQueue_hook_Fn = { _, _ in
swjs_unsafe_event_loop_yield()
}
swift_task_asyncMainDrainQueue_hook = unsafeBitCast(
swift_task_asyncMainDrainQueue_hook_impl,
to: UnsafeMutableRawPointer?.self
)
#endif

typealias swift_task_enqueueGlobal_hook_Fn = @convention(thin) (UnownedJob, swift_task_enqueueGlobal_original)
-> Void
let swift_task_enqueueGlobal_hook_impl: swift_task_enqueueGlobal_hook_Fn = { job, original in
JavaScriptEventLoop.shared.unsafeEnqueue(job)
}
swift_task_enqueueGlobal_hook = unsafeBitCast(
swift_task_enqueueGlobal_hook_impl,
to: UnsafeMutableRawPointer?.self
)

typealias swift_task_enqueueGlobalWithDelay_hook_Fn = @convention(thin) (
UInt64, UnownedJob, swift_task_enqueueGlobalWithDelay_original
) -> Void
let swift_task_enqueueGlobalWithDelay_hook_impl: swift_task_enqueueGlobalWithDelay_hook_Fn = {
nanoseconds,
job,
original in
let milliseconds = Double(nanoseconds / 1_000_000)
JavaScriptEventLoop.shared.enqueue(job, withDelay: milliseconds)
}
swift_task_enqueueGlobalWithDelay_hook = unsafeBitCast(
swift_task_enqueueGlobalWithDelay_hook_impl,
to: UnsafeMutableRawPointer?.self
)

#if compiler(>=5.7)
typealias swift_task_enqueueGlobalWithDeadline_hook_Fn = @convention(thin) (
Int64, Int64, Int64, Int64, Int32, UnownedJob, swift_task_enqueueGlobalWithDelay_original
) -> Void
let swift_task_enqueueGlobalWithDeadline_hook_impl: swift_task_enqueueGlobalWithDeadline_hook_Fn = {
sec,
nsec,
tsec,
tnsec,
clock,
job,
original in
JavaScriptEventLoop.shared.enqueue(job, withDelay: sec, nsec, tsec, tnsec, clock)
}
swift_task_enqueueGlobalWithDeadline_hook = unsafeBitCast(
swift_task_enqueueGlobalWithDeadline_hook_impl,
to: UnsafeMutableRawPointer?.self
)
#endif

typealias swift_task_enqueueMainExecutor_hook_Fn = @convention(thin) (
UnownedJob, swift_task_enqueueMainExecutor_original
) -> Void
let swift_task_enqueueMainExecutor_hook_impl: swift_task_enqueueMainExecutor_hook_Fn = { job, original in
JavaScriptEventLoop.shared.unsafeEnqueue(job)
}
swift_task_enqueueMainExecutor_hook = unsafeBitCast(
swift_task_enqueueMainExecutor_hook_impl,
to: UnsafeMutableRawPointer?.self
)

}
}

#if compiler(>=5.7)
/// Taken from https://github.com/apple/swift/blob/d375c972f12128ec6055ed5f5337bfcae3ec67d8/stdlib/public/Concurrency/Clock.swift#L84-L88
@_silgen_name("swift_get_time")
internal func swift_get_time(
_ seconds: UnsafeMutablePointer<Int64>,
_ nanoseconds: UnsafeMutablePointer<Int64>,
_ clock: CInt
)

@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
extension JavaScriptEventLoop {
fileprivate func enqueue(
_ job: UnownedJob,
withDelay seconds: Int64,
_ nanoseconds: Int64,
_ toleranceSec: Int64,
_ toleranceNSec: Int64,
_ clock: Int32
) {
var nowSec: Int64 = 0
var nowNSec: Int64 = 0
swift_get_time(&nowSec, &nowNSec, clock)
let delayMilliseconds = (seconds - nowSec) * 1_000 + (nanoseconds - nowNSec) / 1_000_000
enqueue(job, withDelay: delayMilliseconds <= 0 ? 0 : Double(delayMilliseconds))
}
}
#endif
108 changes: 10 additions & 98 deletions Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,80 +119,20 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable {
private static func installGlobalExecutorIsolated() {
guard !didInstallGlobalExecutor else { return }
didInstallGlobalExecutor = true

#if compiler(>=5.9)
typealias swift_task_asyncMainDrainQueue_hook_Fn = @convention(thin) (
swift_task_asyncMainDrainQueue_original, swift_task_asyncMainDrainQueue_override
) -> Void
let swift_task_asyncMainDrainQueue_hook_impl: swift_task_asyncMainDrainQueue_hook_Fn = { _, _ in
swjs_unsafe_event_loop_yield()
}
swift_task_asyncMainDrainQueue_hook = unsafeBitCast(
swift_task_asyncMainDrainQueue_hook_impl,
to: UnsafeMutableRawPointer?.self
)
#endif

typealias swift_task_enqueueGlobal_hook_Fn = @convention(thin) (UnownedJob, swift_task_enqueueGlobal_original)
-> Void
let swift_task_enqueueGlobal_hook_impl: swift_task_enqueueGlobal_hook_Fn = { job, original in
JavaScriptEventLoop.shared.unsafeEnqueue(job)
}
swift_task_enqueueGlobal_hook = unsafeBitCast(
swift_task_enqueueGlobal_hook_impl,
to: UnsafeMutableRawPointer?.self
)

typealias swift_task_enqueueGlobalWithDelay_hook_Fn = @convention(thin) (
UInt64, UnownedJob, swift_task_enqueueGlobalWithDelay_original
) -> Void
let swift_task_enqueueGlobalWithDelay_hook_impl: swift_task_enqueueGlobalWithDelay_hook_Fn = {
delay,
job,
original in
JavaScriptEventLoop.shared.enqueue(job, withDelay: delay)
}
swift_task_enqueueGlobalWithDelay_hook = unsafeBitCast(
swift_task_enqueueGlobalWithDelay_hook_impl,
to: UnsafeMutableRawPointer?.self
)

#if compiler(>=5.7)
typealias swift_task_enqueueGlobalWithDeadline_hook_Fn = @convention(thin) (
Int64, Int64, Int64, Int64, Int32, UnownedJob, swift_task_enqueueGlobalWithDelay_original
) -> Void
let swift_task_enqueueGlobalWithDeadline_hook_impl: swift_task_enqueueGlobalWithDeadline_hook_Fn = {
sec,
nsec,
tsec,
tnsec,
clock,
job,
original in
JavaScriptEventLoop.shared.enqueue(job, withDelay: sec, nsec, tsec, tnsec, clock)
#if compiler(>=6.2)
if #available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, visionOS 9999, *) {
// For Swift 6.2 and above, we can use the new `ExecutorFactory` API
_Concurrency._createExecutors(factory: JavaScriptEventLoop.self)
}
swift_task_enqueueGlobalWithDeadline_hook = unsafeBitCast(
swift_task_enqueueGlobalWithDeadline_hook_impl,
to: UnsafeMutableRawPointer?.self
)
#else
// For Swift 6.1 and below, we need to install the global executor by hook API
installByLegacyHook()
#endif

typealias swift_task_enqueueMainExecutor_hook_Fn = @convention(thin) (
UnownedJob, swift_task_enqueueMainExecutor_original
) -> Void
let swift_task_enqueueMainExecutor_hook_impl: swift_task_enqueueMainExecutor_hook_Fn = { job, original in
JavaScriptEventLoop.shared.unsafeEnqueue(job)
}
swift_task_enqueueMainExecutor_hook = unsafeBitCast(
swift_task_enqueueMainExecutor_hook_impl,
to: UnsafeMutableRawPointer?.self
)
}

private func enqueue(_ job: UnownedJob, withDelay nanoseconds: UInt64) {
let milliseconds = nanoseconds / 1_000_000
internal func enqueue(_ job: UnownedJob, withDelay milliseconds: Double) {
setTimeout(
Double(milliseconds),
milliseconds,
{
#if compiler(>=5.9)
job.runSynchronously(on: self.asUnownedSerialExecutor())
Expand All @@ -203,7 +143,7 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable {
)
}

private func unsafeEnqueue(_ job: UnownedJob) {
internal func unsafeEnqueue(_ job: UnownedJob) {
#if canImport(wasi_pthread) && compiler(>=6.1) && _runtime(_multithreaded)
guard swjs_get_worker_thread_id_cached() == SWJS_MAIN_THREAD_ID else {
// Notify the main thread to execute the job when a job is
Expand Down Expand Up @@ -237,34 +177,6 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable {
}
}

#if compiler(>=5.7)
/// Taken from https://github.com/apple/swift/blob/d375c972f12128ec6055ed5f5337bfcae3ec67d8/stdlib/public/Concurrency/Clock.swift#L84-L88
@_silgen_name("swift_get_time")
internal func swift_get_time(
_ seconds: UnsafeMutablePointer<Int64>,
_ nanoseconds: UnsafeMutablePointer<Int64>,
_ clock: CInt
)

@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
extension JavaScriptEventLoop {
fileprivate func enqueue(
_ job: UnownedJob,
withDelay seconds: Int64,
_ nanoseconds: Int64,
_ toleranceSec: Int64,
_ toleranceNSec: Int64,
_ clock: Int32
) {
var nowSec: Int64 = 0
var nowNSec: Int64 = 0
swift_get_time(&nowSec, &nowNSec, clock)
let delayNanosec = (seconds - nowSec) * 1_000_000_000 + (nanoseconds - nowNSec)
enqueue(job, withDelay: delayNanosec <= 0 ? 0 : UInt64(delayNanosec))
}
}
#endif

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
extension JSPromise {
/// Wait for the promise to complete, returning (or throwing) its result.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase {
}
}
let taskRunOnMainThread = await task.value
// FIXME: The block passed to `MainActor.run` should run on the main thread
// XCTAssertTrue(taskRunOnMainThread)
XCTAssertFalse(taskRunOnMainThread)
XCTAssertTrue(taskRunOnMainThread)
// After the task is done, back to the main thread
XCTAssertTrue(isMainThread())

Expand Down