diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69f63a27c..419c772a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,7 @@ jobs: target: "wasm32-unknown-wasip1" - os: ubuntu-24.04 toolchain: - download-url: https://download.swift.org/swift-6.3-branch/ubuntu2404/swift-6.3-DEVELOPMENT-SNAPSHOT-2025-12-05-a/swift-6.3-DEVELOPMENT-SNAPSHOT-2025-12-05-a-ubuntu24.04.tar.gz + download-url: https://download.swift.org/swift-6.3-branch/ubuntu2404/swift-6.3-DEVELOPMENT-SNAPSHOT-2026-03-05-a/swift-6.3-DEVELOPMENT-SNAPSHOT-2026-03-05-a-ubuntu24.04.tar.gz wasi-backend: Node target: "wasm32-unknown-wasip1" - os: ubuntu-22.04 @@ -167,14 +167,16 @@ jobs: - uses: actions/checkout@v6 - uses: ./.github/actions/install-swift with: - download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2026-02-02-a/swift-DEVELOPMENT-SNAPSHOT-2026-02-02-a-ubuntu22.04.tar.gz + download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2026-03-09-a/swift-DEVELOPMENT-SNAPSHOT-2026-03-09-a-ubuntu22.04.tar.gz - uses: swiftwasm/setup-swiftwasm@v2 id: setup-wasm32-unknown-wasip1 with: { target: wasm32-unknown-wasip1 } - uses: swiftwasm/setup-swiftwasm@v2 id: setup-wasm32-unknown-wasip1-threads with: { target: wasm32-unknown-wasip1-threads } - - run: ./Utilities/build-examples.sh + - run: | + swift --version + ./Utilities/build-examples.sh env: SWIFT_SDK_ID_wasm32_unknown_wasip1_threads: ${{ steps.setup-wasm32-unknown-wasip1-threads.outputs.swift-sdk-id }} SWIFT_SDK_ID_wasm32_unknown_wasip1: ${{ steps.setup-wasm32-unknown-wasip1.outputs.swift-sdk-id }} diff --git a/Examples/Embedded/Sources/EmbeddedApp/main.swift b/Examples/Embedded/Sources/EmbeddedApp/main.swift index f6bf5b6ac..5e7f01a3c 100644 --- a/Examples/Embedded/Sources/EmbeddedApp/main.swift +++ b/Examples/Embedded/Sources/EmbeddedApp/main.swift @@ -3,7 +3,7 @@ import JavaScriptKit let alert = JSObject.global.alert.object! let document = JSObject.global.document -print("Hello from WASM, document title: \(document.title.string ?? "")") +print("Hello from Wasm, document title: \(document.title.string ?? "")") var count = 0 diff --git a/Examples/EmbeddedConcurrency/Package.swift b/Examples/EmbeddedConcurrency/Package.swift new file mode 100644 index 000000000..59dfecaca --- /dev/null +++ b/Examples/EmbeddedConcurrency/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:6.0 + +import PackageDescription + +let package = Package( + name: "EmbeddedConcurrency", + dependencies: [ + .package(name: "JavaScriptKit", path: "../../") + ], + targets: [ + .executableTarget( + name: "EmbeddedConcurrencyApp", + dependencies: [ + "JavaScriptKit", + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit"), + ], + swiftSettings: [ + .enableExperimentalFeature("Extern"), + .swiftLanguageMode(.v5), + ] + ) + ] +) diff --git a/Examples/EmbeddedConcurrency/Sources/EmbeddedConcurrencyApp/App.swift b/Examples/EmbeddedConcurrency/Sources/EmbeddedConcurrencyApp/App.swift new file mode 100644 index 000000000..95b0a86aa --- /dev/null +++ b/Examples/EmbeddedConcurrency/Sources/EmbeddedConcurrencyApp/App.swift @@ -0,0 +1,162 @@ +@preconcurrency import JavaScriptKit +@preconcurrency import JavaScriptEventLoop +import _Concurrency + +#if compiler(>=6.3) +typealias DefaultExecutorFactory = JavaScriptEventLoop +#endif + +@MainActor var testsPassed = 0 +@MainActor var testsFailed = 0 + +@MainActor +func check(_ condition: Bool, _ message: String) { + let console = JSObject.global.console + if condition { + testsPassed += 1 + _ = console.log("PASS: \(message)") + } else { + testsFailed += 1 + _ = console.log("FAIL: \(message)") + } +} + +@main +struct App { + @MainActor + static func main() async throws(JSException) { + JavaScriptEventLoop.installGlobalExecutor() + + // Test 1: Basic async/await with checked continuation + let value: Int = await withCheckedContinuation { cont in + cont.resume(returning: 42) + } + check(value == 42, "withCheckedContinuation returns correct value") + + // Test 2: Unsafe continuation + let value2: Int = await withUnsafeContinuation { cont in + cont.resume(returning: 7) + } + check(value2 == 7, "withUnsafeContinuation returns correct value") + + // Test 3: JSPromise creation and .value await + let promise = JSPromise(resolver: { resolve in + resolve(.success(JSValue.number(123))) + }) + let result: JSPromise.Result = await withUnsafeContinuation { continuation in + promise.then( + success: { + continuation.resume(returning: .success($0)) + return JSValue.undefined + }, + failure: { + continuation.resume(returning: .failure($0)) + return JSValue.undefined + } + ) + } + if case .success(let val) = result { + check(val.number == 123, "JSPromise.value resolves correctly") + } else { + check(false, "JSPromise.value resolves correctly") + } + + // Test 4: setTimeout-based delay via JSPromise + let startTime = JSObject.global.Date.now().number! + let delayValue: Int = await withUnsafeContinuation { cont in + _ = JSObject.global.setTimeout!( + JSOneshotClosure { _ in + cont.resume(returning: 42) + return .undefined + }, + 100 + ) + } + let elapsed = JSObject.global.Date.now().number! - startTime + check(delayValue == 42 && elapsed >= 90, "setTimeout delay works (\(elapsed)ms elapsed)") + + // Test 5: Multiple concurrent tasks (using withUnsafeContinuation to avoid nonisolated hop) + var results: [Int] = [] + let task1 = Task { return 1 } + let task2 = Task { return 2 } + let task3 = Task { return 3 } + let r1: Int = await withUnsafeContinuation { cont in + Task { cont.resume(returning: await task1.value) } + } + let r2: Int = await withUnsafeContinuation { cont in + Task { cont.resume(returning: await task2.value) } + } + let r3: Int = await withUnsafeContinuation { cont in + Task { cont.resume(returning: await task3.value) } + } + results.append(r1) + results.append(r2) + results.append(r3) + results.sort() + check(results == [1, 2, 3], "Concurrent tasks all complete") + + // Test 6: Promise chaining with .then + let chained = JSPromise(resolver: { resolve in + resolve(.success(JSValue.number(10))) + }).then(success: { value in + return JSValue.number(value.number! * 2) + }).then(success: { value in + return JSValue.number(value.number! + 5) + }) + let chainedResult: JSPromise.Result = await withUnsafeContinuation { continuation in + chained.then( + success: { + continuation.resume(returning: .success($0)) + return JSValue.undefined + }, + failure: { + continuation.resume(returning: .failure($0)) + return JSValue.undefined + } + ) + } + if case .success(let val) = chainedResult { + check(val.number == 25, "Promise chaining works (10 * 2 + 5 = 25)") + } else { + check(false, "Promise chaining should succeed") + } + + // Test 7: JSPromise.value await (with async resolution) + let promise2 = JSPromise(resolver: { resolve in + _ = JSObject.global.setTimeout!( + JSOneshotClosure { _ in + resolve(.success(JSValue.number(456))) + return .undefined + }, + 1 + ) + }) + let awaitedValue = try await promise2.value + check(awaitedValue.number == 456, "JSPromise.value await returns correct value") + + // Test 8: JSPromise.result await (with async resolution) + let promise3 = JSPromise(resolver: { resolve in + _ = JSObject.global.setTimeout!( + JSOneshotClosure { _ in + resolve(.success(JSValue.number(789))) + return .undefined + }, + 1 + ) + }) + let awaitedResult = await promise3.result + if case .success(let val) = awaitedResult { + check(val.number == 789, "JSPromise.result await resolves correctly") + } else { + check(false, "JSPromise.result await should succeed") + } + + // Summary + let console = JSObject.global.console + let totalTests = testsPassed + testsFailed + _ = console.log("TOTAL: \(totalTests) tests, \(testsPassed) passed, \(testsFailed) failed") + if testsFailed > 0 { + fatalError("\(testsFailed) test(s) failed") + } + } +} diff --git a/Examples/EmbeddedConcurrency/build.sh b/Examples/EmbeddedConcurrency/build.sh new file mode 100755 index 000000000..d09c1d2a4 --- /dev/null +++ b/Examples/EmbeddedConcurrency/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euxo pipefail +package_dir="$(cd "$(dirname "$0")" && pwd)" +swift package --package-path "$package_dir" \ + --swift-sdk "${SWIFT_SDK_ID_wasm32_unknown_wasip1:-${SWIFT_SDK_ID:-wasm32-unknown-wasip1}}-embedded" \ + js --default-platform node +npm -C "$package_dir/.build/plugins/PackageToJS/outputs/Package" install +node "$package_dir/run.mjs" diff --git a/Examples/EmbeddedConcurrency/run.mjs b/Examples/EmbeddedConcurrency/run.mjs new file mode 100644 index 000000000..2d755923c --- /dev/null +++ b/Examples/EmbeddedConcurrency/run.mjs @@ -0,0 +1,47 @@ +import { instantiate } from + "./.build/plugins/PackageToJS/outputs/Package/instantiate.js" +import { defaultNodeSetup } from + "./.build/plugins/PackageToJS/outputs/Package/platforms/node.js" + +const EXPECTED_TESTS = 8; +const TIMEOUT_MS = 30_000; + +// Intercept console.log to capture test output +const originalLog = console.log; +let totalLine = null; +let resolveTotal = null; +const totalPromise = new Promise((resolve) => { resolveTotal = resolve; }); +console.log = (...args) => { + const line = args.join(" "); + originalLog.call(console, ...args); + if (line.startsWith("TOTAL:")) { + totalLine = line; + resolveTotal(); + } +}; + +const options = await defaultNodeSetup(); +await instantiate(options); + +// Wait for the async main to complete (tests run via microtasks/setTimeout) +const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("Timed out waiting for test results")), TIMEOUT_MS) +); +try { + await Promise.race([totalPromise, timeout]); +} catch (e) { + originalLog.call(console, `FAIL: ${e.message}`); + process.exit(1); +} + +if (!totalLine) { + originalLog.call(console, `FAIL: No test summary found — main() likely exited early`); + process.exit(1); +} +const match = totalLine.match(/TOTAL: (\d+) tests/); +const ran = match ? parseInt(match[1], 10) : 0; +if (ran !== EXPECTED_TESTS) { + originalLog.call(console, + `FAIL: Expected ${EXPECTED_TESTS} tests but only ${ran} ran`); + process.exit(1); +} diff --git a/Sources/JavaScriptEventLoop/JSSending.swift b/Sources/JavaScriptEventLoop/JSSending.swift index 7a3750c15..fb2fb1ddf 100644 --- a/Sources/JavaScriptEventLoop/JSSending.swift +++ b/Sources/JavaScriptEventLoop/JSSending.swift @@ -226,6 +226,32 @@ extension JSSending { /// - Parameter isolation: The actor isolation context for this call, used in Swift concurrency. /// - Returns: The received object of type `T`. /// - Throws: `JSSendingError` if the sending operation fails, or `JSException` if a JavaScript error occurs. + #if compiler(>=6.4) + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func receive( + isolation: isolated (any Actor)? = #isolation, + file: StaticString = #file, + line: UInt = #line + ) async throws(JSException) -> T { + #if _runtime(_multithreaded) + let idInDestination = try await withCheckedThrowingContinuation { continuation in + let context = _JSSendingContext(continuation: continuation) + let idInSource = self.storage.idInSource + let transferring = self.storage.transferring ? [idInSource] : [] + swjs_request_sending_object( + idInSource, + transferring, + Int32(transferring.count), + self.storage.sourceTid, + Unmanaged.passRetained(context).toOpaque() + ) + } + return storage.construct(JSObject(id: idInDestination)) + #else + return storage.construct(storage.sourceObject) + #endif + } + #else @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public func receive( isolation: isolated (any Actor)? = #isolation, @@ -250,6 +276,7 @@ extension JSSending { return storage.construct(storage.sourceObject) #endif } + #endif // 6.0 and below can't compile the following without a compiler crash. #if compiler(>=6.1) @@ -341,11 +368,19 @@ extension JSSending { @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) private final class _JSSendingContext: Sendable { + #if compiler(>=6.4) + let continuation: CheckedContinuation + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + #else let continuation: CheckedContinuation init(continuation: CheckedContinuation) { self.continuation = continuation } + #endif } /// Error type representing failures during JavaScript object sending operations. diff --git a/Sources/JavaScriptEventLoop/JavaScriptEventLoop+ExecutorFactory.swift b/Sources/JavaScriptEventLoop/JavaScriptEventLoop+ExecutorFactory.swift index 7de4cb74a..a9b6091e8 100644 --- a/Sources/JavaScriptEventLoop/JavaScriptEventLoop+ExecutorFactory.swift +++ b/Sources/JavaScriptEventLoop/JavaScriptEventLoop+ExecutorFactory.swift @@ -5,10 +5,10 @@ // See: https://forums.swift.org/t/pitch-2-custom-main-and-global-executors/78437 #if compiler(>=6.3) -@_spi(ExperimentalCustomExecutors) import _Concurrency +@_spi(ExperimentalCustomExecutors) @_spi(ExperimentalScheduling) import _Concurrency #else import _Concurrency -#endif +#endif // #if compiler(>=6.3) import _CJavaScriptKit #if compiler(>=6.3) @@ -40,6 +40,22 @@ extension JavaScriptEventLoop: SchedulingExecutor { tolerance: C.Duration?, clock: C ) { + #if hasFeature(Embedded) + #if compiler(>=6.4) + // In Embedded Swift, ContinuousClock and SuspendingClock are unavailable. + // Hand-off the scheduling work to the Clock implementation for custom clocks. + clock.enqueue( + job, + on: self, + at: clock.now.advanced(by: delay), + tolerance: tolerance + ) + #else + fatalError( + "Delayed enqueue requires Swift 6.4+ in Embedded mode" + ) + #endif // #if compiler(>=6.4) (Embedded) + #else // #if hasFeature(Embedded) let duration: Duration // Handle clocks we know if let _ = clock as? ContinuousClock { @@ -47,7 +63,9 @@ extension JavaScriptEventLoop: SchedulingExecutor { } else if let _ = clock as? SuspendingClock { duration = delay as! SuspendingClock.Duration } else { - // Hand-off the scheduling work to Clock implementation for unknown clocks + #if compiler(>=6.4) + // Hand-off the scheduling work to Clock implementation for unknown clocks. + // Clock.enqueue is only available in the development branch (6.4+). clock.enqueue( job, on: self, @@ -55,12 +73,16 @@ extension JavaScriptEventLoop: SchedulingExecutor { tolerance: tolerance ) return + #else + fatalError("Unsupported clock type; only ContinuousClock and SuspendingClock are supported") + #endif // #if compiler(>=6.4) (non-Embedded) } let milliseconds = Self.delayInMilliseconds(from: duration) self.enqueue( UnownedJob(job), withDelay: milliseconds ) + #endif // #if hasFeature(Embedded) } private static func delayInMilliseconds(from swiftDuration: Duration) -> Double { @@ -111,4 +133,4 @@ extension JavaScriptEventLoop: ExecutorFactory { } } -#endif // compiler(>=6.3) +#endif // #if compiler(>=6.3) diff --git a/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift b/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift index aebc90d65..aec1441a5 100644 --- a/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift +++ b/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift @@ -123,13 +123,16 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable { private static func installGlobalExecutorIsolated() { guard !didInstallGlobalExecutor else { return } didInstallGlobalExecutor = true - #if compiler(>=6.3) + #if compiler(>=6.3) && !hasFeature(Embedded) if #available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, visionOS 9999, *) { // For Swift 6.3 and above, we can use the new `ExecutorFactory` API _Concurrency._createExecutors(factory: JavaScriptEventLoop.self) } #else - // For Swift 6.1 and below, we need to install the global executor by hook API + // For Swift 6.1 and below, or Embedded Swift, we need to install + // the global executor by hook API. The ExecutorFactory mechanism + // does not work in Embedded Swift because ExecutorImpl.swift is + // excluded from the embedded Concurrency library. installByLegacyHook() #endif } diff --git a/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift b/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift index dceecf5bf..0ad7b235a 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift @@ -98,7 +98,7 @@ public final class JSTypedArray: JSBridgedClass, ExpressibleByArrayLiter /// used as the return value for the `withUnsafeBytes(_:)` method. The /// argument is valid only for the duration of the closure's execution. /// - Returns: The return value, if any, of the `body` closure parameter. - public func withUnsafeBytes(_ body: (UnsafeBufferPointer) throws -> R) rethrows -> R { + public func withUnsafeBytes(_ body: (UnsafeBufferPointer) throws(E) -> R) throws(E) -> R { let buffer = UnsafeMutableBufferPointer.allocate(capacity: length) defer { buffer.deallocate() } copyMemory(to: buffer) @@ -121,7 +121,9 @@ public final class JSTypedArray: JSBridgedClass, ExpressibleByArrayLiter /// argument is valid only for the duration of the closure's execution. /// - Returns: The return value, if any, of the `body`async closure parameter. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public func withUnsafeBytesAsync(_ body: (UnsafeBufferPointer) async throws -> R) async rethrows -> R { + public func withUnsafeBytesAsync( + _ body: (UnsafeBufferPointer) async throws(E) -> R + ) async throws(E) -> R { let buffer = UnsafeMutableBufferPointer.allocate(capacity: length) defer { buffer.deallocate() } copyMemory(to: buffer)