Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Examples/Embedded/Sources/EmbeddedApp/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions Examples/EmbeddedConcurrency/Package.swift
Original file line number Diff line number Diff line change
@@ -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),
]
)
]
)
132 changes: 132 additions & 0 deletions Examples/EmbeddedConcurrency/Sources/EmbeddedConcurrencyApp/App.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
@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")
}

// 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")
}
}
}
8 changes: 8 additions & 0 deletions Examples/EmbeddedConcurrency/build.sh
Original file line number Diff line number Diff line change
@@ -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"
47 changes: 47 additions & 0 deletions Examples/EmbeddedConcurrency/run.mjs
Original file line number Diff line number Diff line change
@@ -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 = 6;
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);
}
35 changes: 35 additions & 0 deletions Sources/JavaScriptEventLoop/JSSending.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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<JavaScriptObjectRef, JSException>

init(continuation: CheckedContinuation<JavaScriptObjectRef, JSException>) {
self.continuation = continuation
}
#else
let continuation: CheckedContinuation<JavaScriptObjectRef, Error>

init(continuation: CheckedContinuation<JavaScriptObjectRef, Error>) {
self.continuation = continuation
}
#endif
}

/// Error type representing failures during JavaScript object sending operations.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// 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
Expand Down Expand Up @@ -40,27 +40,36 @@ extension JavaScriptEventLoop: SchedulingExecutor {
tolerance: C.Duration?,
clock: C
) {
#if !hasFeature(Embedded)
let duration: Duration
// Handle clocks we know
if let _ = clock as? ContinuousClock {
duration = delay as! ContinuousClock.Duration
} 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(
Comment thread
MaxDesiatov marked this conversation as resolved.
job,
on: self,
at: clock.now.advanced(by: delay),
tolerance: tolerance
)
return
#else
fatalError("Unsupported clock type; only ContinuousClock and SuspendingClock are supported")
#endif
}
let milliseconds = Self.delayInMilliseconds(from: duration)
self.enqueue(
UnownedJob(job),
withDelay: milliseconds
)
#else
fatalError("SchedulingExecutor.enqueue is not supported in embedded mode")
#endif
}

private static func delayInMilliseconds(from swiftDuration: Duration) -> Double {
Expand All @@ -81,6 +90,7 @@ extension JavaScriptEventLoop: ExecutorFactory {
JavaScriptEventLoop.shared.enqueue(job)
}

#if !hasFeature(Embedded)
func enqueue<C: Clock>(
_ job: consuming ExecutorJob,
after delay: C.Duration,
Expand All @@ -94,6 +104,7 @@ extension JavaScriptEventLoop: ExecutorFactory {
clock: clock
)
}
#endif
func run() throws {
try JavaScriptEventLoop.shared.run()
}
Expand Down
7 changes: 5 additions & 2 deletions Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public final class JSTypedArray<Traits>: 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<R>(_ body: (UnsafeBufferPointer<Element>) throws -> R) rethrows -> R {
public func withUnsafeBytes<R, E: Error>(_ body: (UnsafeBufferPointer<Element>) throws(E) -> R) throws(E) -> R {
let buffer = UnsafeMutableBufferPointer<Element>.allocate(capacity: length)
defer { buffer.deallocate() }
copyMemory(to: buffer)
Expand All @@ -121,7 +121,9 @@ public final class JSTypedArray<Traits>: 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<R>(_ body: (UnsafeBufferPointer<Element>) async throws -> R) async rethrows -> R {
public func withUnsafeBytesAsync<R, E: Error>(
_ body: (UnsafeBufferPointer<Element>) async throws(E) -> R
) async throws(E) -> R {
let buffer = UnsafeMutableBufferPointer<Element>.allocate(capacity: length)
defer { buffer.deallocate() }
copyMemory(to: buffer)
Expand Down
Loading