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
14 changes: 10 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ jobs:
integration-test:
runs-on: macos-14
timeout-minutes: 30
strategy:
matrix:
# NOTE: As explained in `README.md`, the minimum supported Swift version is currently `6.0`.
# If supporting `5.0` ever becomes difficult we can consider removing it from the test matrix.
swift_version: ['5.0', '6.0']

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, our minimum support Swift version is 6.0, so if 5.0 ever causes problems we can remove it

swift-bridge/README.md

Lines 170 to 173 in 82b0885

## Minimum Supported Swift Version (MSSV)
`swift-bridge` currently guarantees that the Swift code that it generates will work on Swift `6.0` and later.
This is known the project's "Minimum Supported Swift Version" (MSSV).

Let's leave leave a comment here in this test.yml. Something like:

# NOTE: As explained in `README.md`, the minimum supported Swift version is currently `6.0`.
#   If supporting `5.0` ever becomes difficult we can consider removing it from the test matrix.


steps:
- uses: actions/checkout@v2
Expand All @@ -61,15 +66,16 @@ jobs:
with:
toolchain: stable

# Xcode 16.2 provides Swift 5.9+ which is required for typed throws.
# Xcode 16.2 provides Swift 5.9+ and Swift 6.0, which we make use of in the test matrix above.
# If we need to support other versions in the future we can change this Xcode version.
- name: Set Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.2.app

- name: Add rust targets
run: rustup target add aarch64-apple-darwin x86_64-apple-darwin

- name: Run integration tests
run: ./test-swift-rust-integration.sh
- name: Run integration tests (Swift ${{ matrix.swift_version }})
run: SWIFT_VERSION=${{ matrix.swift_version }} ./test-swift-rust-integration.sh

build-examples:
runs-on: macos-14
Expand All @@ -95,4 +101,4 @@ jobs:
run: cargo build -p rust-binary-calls-swift-package

- name: Build without-a-bridge-module example
run: cargo build -p without-a-bridge-module
run: cargo build -p without-a-bridge-module
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import XCTest
@testable import SwiftRustIntegrationTestRunner

@MainActor
class OpaqueRustStructTests: XCTestCase {

override func setUpWithError() throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import XCTest
@testable import SwiftRustIntegrationTestRunner

@MainActor
class ResultTests: XCTestCase {
/// Verify that we can pass a Result<String, String> from Swift -> Rust
func testSwiftCallRustResultString() throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import XCTest
@testable import SwiftRustIntegrationTestRunner

@MainActor
class StringTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import XCTest
@testable import SwiftRustIntegrationTestRunner

/// Tests tuples
@MainActor
final class TupleTest: XCTestCase {
/// Verify that we can pass and return Rust tuples.
func testSwiftCallsRustTuples() throws {
Expand Down
12 changes: 12 additions & 0 deletions crates/swift-bridge-build/src/generate_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ fn core_swift() -> String {

core_swift += &generic_freer();
core_swift += &generic_copy_type_ffi_repr();
core_swift += &unchecked_sendable_wrapper();

core_swift
}
Expand Down Expand Up @@ -212,3 +213,14 @@ fn generic_copy_type_ffi_repr() -> &'static str {
protocol SwiftBridgeGenericCopyTypeFfiRepr {}
"#
}

/// A wrapper type that makes any value Sendable for use in Task closures.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clear documentation about safety.

/// This is used for FFI callbacks that we know are safe to use across Task boundaries.
fn unchecked_sendable_wrapper() -> &'static str {
r#"
public struct __private__UncheckedSendable<T>: @unchecked Sendable {
public let value: T
@inlinable public init(_ value: T) { self.value = value }
}
"#
}
22 changes: 22 additions & 0 deletions crates/swift-bridge-ir/src/codegen/codegen_tests/async_function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1100,7 +1100,9 @@ mod extern_swift_async_function_no_return {
r#"
@_cdecl("__swift_bridge__$some_function")
func __swift_bridge__some_function (_ callbackWrapper: UnsafeMutableRawPointer, _ callback: @escaping @convention(c) (UnsafeMutableRawPointer) -> Void) {
let __callbacks = __private__UncheckedSendable((callbackWrapper, callback))
Task {
let (callbackWrapper, callback) = __callbacks.value
let _ = await some_function()
callback(callbackWrapper)
}
Expand Down Expand Up @@ -1176,7 +1178,9 @@ mod extern_swift_async_function_returns_u8 {
r#"
@_cdecl("__swift_bridge__$some_function")
func __swift_bridge__some_function (_ callbackWrapper: UnsafeMutableRawPointer, _ callback: @escaping @convention(c) (UnsafeMutableRawPointer, UInt8) -> Void) {
let __callbacks = __private__UncheckedSendable((callbackWrapper, callback))
Task {
let (callbackWrapper, callback) = __callbacks.value
let result = await some_function()
callback(callbackWrapper, result)
}
Expand Down Expand Up @@ -1252,7 +1256,9 @@ mod extern_swift_async_function_with_args {
r#"
@_cdecl("__swift_bridge__$some_function")
func __swift_bridge__some_function (_ callbackWrapper: UnsafeMutableRawPointer, _ callback: @escaping @convention(c) (UnsafeMutableRawPointer, UInt8) -> Void, _ arg: UInt32) {
let __callbacks = __private__UncheckedSendable((callbackWrapper, callback))
Task {
let (callbackWrapper, callback) = __callbacks.value
let result = await some_function(arg: arg)
callback(callbackWrapper, result)
}
Expand Down Expand Up @@ -1338,7 +1344,9 @@ mod extern_swift_async_function_returns_result {
r#"
@_cdecl("__swift_bridge__$some_function")
func __swift_bridge__some_function (_ callbackWrapper: UnsafeMutableRawPointer, _ onSuccess: @escaping @convention(c) (UnsafeMutableRawPointer, UInt32) -> Void, _ onError: @escaping @convention(c) (UnsafeMutableRawPointer, UnsafeMutableRawPointer) -> Void) {
let __callbacks = __private__UncheckedSendable((callbackWrapper, onSuccess, onError))
Task {
let (callbackWrapper, onSuccess, onError) = __callbacks.value
do {
let result = try await some_function()
onSuccess(callbackWrapper, result)
Expand Down Expand Up @@ -1432,7 +1440,9 @@ mod extern_swift_async_function_returns_result_with_args {
r#"
@_cdecl("__swift_bridge__$some_function")
func __swift_bridge__some_function (_ callbackWrapper: UnsafeMutableRawPointer, _ onSuccess: @escaping @convention(c) (UnsafeMutableRawPointer, UInt32) -> Void, _ onError: @escaping @convention(c) (UnsafeMutableRawPointer, UnsafeMutableRawPointer) -> Void, _ arg: UInt32) {
let __callbacks = __private__UncheckedSendable((callbackWrapper, onSuccess, onError))
Task {
let (callbackWrapper, onSuccess, onError) = __callbacks.value
do {
let result = try await some_function(arg: arg)
onSuccess(callbackWrapper, result)
Expand Down Expand Up @@ -1524,7 +1534,9 @@ mod extern_swift_async_function_returns_result_void_ok {
r#"
@_cdecl("__swift_bridge__$some_function")
func __swift_bridge__some_function (_ callbackWrapper: UnsafeMutableRawPointer, _ onSuccess: @escaping @convention(c) (UnsafeMutableRawPointer) -> Void, _ onError: @escaping @convention(c) (UnsafeMutableRawPointer, UnsafeMutableRawPointer) -> Void) {
let __callbacks = __private__UncheckedSendable((callbackWrapper, onSuccess, onError))
Task {
let (callbackWrapper, onSuccess, onError) = __callbacks.value
do {
_ = try await some_function()
onSuccess(callbackWrapper)
Expand Down Expand Up @@ -1617,7 +1629,9 @@ mod extern_swift_async_function_returns_result_void_ok_with_args {
r#"
@_cdecl("__swift_bridge__$some_function")
func __swift_bridge__some_function (_ callbackWrapper: UnsafeMutableRawPointer, _ onSuccess: @escaping @convention(c) (UnsafeMutableRawPointer) -> Void, _ onError: @escaping @convention(c) (UnsafeMutableRawPointer, UnsafeMutableRawPointer) -> Void, _ arg: UInt32) {
let __callbacks = __private__UncheckedSendable((callbackWrapper, onSuccess, onError))
Task {
let (callbackWrapper, onSuccess, onError) = __callbacks.value
do {
_ = try await some_function(arg: arg)
onSuccess(callbackWrapper)
Expand Down Expand Up @@ -1699,7 +1713,9 @@ mod extern_swift_async_method_with_self {
r#"
@_cdecl("__swift_bridge__$SomeType$some_method")
func __swift_bridge__SomeType_some_method (_ callbackWrapper: UnsafeMutableRawPointer, _ callback: @escaping @convention(c) (UnsafeMutableRawPointer, UInt32) -> Void, _ this: UnsafeMutableRawPointer) {
let __captures = __private__UncheckedSendable((callbackWrapper, callback, this))
Task {
let (callbackWrapper, callback, this) = __captures.value
let result = await Unmanaged<SomeType>.fromOpaque(this).takeUnretainedValue().some_method()
callback(callbackWrapper, result)
}
Expand Down Expand Up @@ -1776,7 +1792,9 @@ mod extern_swift_async_method_with_self_and_args {
r#"
@_cdecl("__swift_bridge__$SomeType$some_method")
func __swift_bridge__SomeType_some_method (_ callbackWrapper: UnsafeMutableRawPointer, _ callback: @escaping @convention(c) (UnsafeMutableRawPointer, UInt8) -> Void, _ this: UnsafeMutableRawPointer, _ arg1: UInt32, _ arg2: UnsafeMutableRawPointer) {
let __captures = __private__UncheckedSendable((callbackWrapper, callback, this))
Task {
let (callbackWrapper, callback, this) = __captures.value
let result = await Unmanaged<SomeType>.fromOpaque(this).takeUnretainedValue().some_method(arg1: arg1, arg2: RustString(ptr: arg2))
callback(callbackWrapper, result)
}
Expand Down Expand Up @@ -1863,7 +1881,9 @@ mod extern_swift_async_method_with_self_returns_result {
r#"
@_cdecl("__swift_bridge__$SomeType$some_method")
func __swift_bridge__SomeType_some_method (_ callbackWrapper: UnsafeMutableRawPointer, _ onSuccess: @escaping @convention(c) (UnsafeMutableRawPointer, UInt32) -> Void, _ onError: @escaping @convention(c) (UnsafeMutableRawPointer, UnsafeMutableRawPointer) -> Void, _ this: UnsafeMutableRawPointer, _ arg: UInt32) {
let __captures = __private__UncheckedSendable((callbackWrapper, onSuccess, onError, this))
Task {
let (callbackWrapper, onSuccess, onError, this) = __captures.value
do {
let result = try await Unmanaged<SomeType>.fromOpaque(this).takeUnretainedValue().some_method(arg: arg)
onSuccess(callbackWrapper, result)
Expand Down Expand Up @@ -1952,7 +1972,9 @@ mod extern_swift_async_method_with_self_returns_result_void_ok {
r#"
@_cdecl("__swift_bridge__$SomeType$some_method")
func __swift_bridge__SomeType_some_method (_ callbackWrapper: UnsafeMutableRawPointer, _ onSuccess: @escaping @convention(c) (UnsafeMutableRawPointer) -> Void, _ onError: @escaping @convention(c) (UnsafeMutableRawPointer, UnsafeMutableRawPointer) -> Void, _ this: UnsafeMutableRawPointer) {
let __captures = __private__UncheckedSendable((callbackWrapper, onSuccess, onError, this))
Task {
let (callbackWrapper, onSuccess, onError, this) = __captures.value
do {
_ = try await Unmanaged<SomeType>.fromOpaque(this).takeUnretainedValue().some_method()
onSuccess(callbackWrapper)
Expand Down
85 changes: 71 additions & 14 deletions crates/swift-bridge-ir/src/codegen/generate_swift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,10 @@ fn gen_async_function_exposes_swift_to_rust(
// Build the async call expression
let call_expression = build_swift_call_expression(func, fn_name, &args);

// Build params_str, task_body, and optional typed_throws_check based on whether this is a Result type or not
let (params_str, task_body, typed_throws_check) = if let Some(result) = maybe_result {
// Build params_str, task_body, optional typed_throws_check, and pre_task_bindings based on whether this is a Result type or not
let (params_str, task_body, typed_throws_check, pre_task_bindings) = if let Some(result) =
maybe_result
{
// Result type: generate two callbacks (on_success and on_error)

// For the catch clause, we need the actual Swift wrapper type name (e.g., "ErrorType"),
Expand Down Expand Up @@ -372,15 +374,6 @@ fn gen_async_function_exposes_swift_to_rust(
all_params.push(original_params.clone());
}

let task_body = format!(
r#"do {{
{result_binding}try await {call_expression}
{on_success_call}
}} catch let error as {err_swift_ty} {{
onError(callbackWrapper, {err_ffi_convert})
}}"#
);

// Generate a typed throw checker function that verifies at compile-time
// that the Swift function only throws the expected error type.
// This uses Swift's typed throws feature (Swift 5.9+).
Expand All @@ -396,7 +389,26 @@ func {prefixed_fn_name}__TypedThrowsCheck({checker_params}) async throws({err_sw
}}"#
);

(all_params.join(", "), task_body, Some(typed_throws_check))
// Pre-task wrapper for Swift 6 sendability - wrap callbacks in UncheckedSendable
let pre_task_bindings =
"let __callbacks = __private__UncheckedSendable((callbackWrapper, onSuccess, onError))";
// Destructure at start of Task body
let task_body = format!(
r#"let (callbackWrapper, onSuccess, onError) = __callbacks.value
do {{
{result_binding}try await {call_expression}
{on_success_call}
}} catch let error as {err_swift_ty} {{
onError(callbackWrapper, {err_ffi_convert})
}}"#
);

(
all_params.join(", "),
task_body,
Some(typed_throws_check),
pre_task_bindings.to_string(),
)
} else {
// Non-Result type: single callback
let return_ty_ref = return_ty.as_ref();
Expand Down Expand Up @@ -445,16 +457,61 @@ func {prefixed_fn_name}__TypedThrowsCheck({checker_params}) async throws({err_sw
"let _ = "
};

let task_body = format!("{result_binding}await {call_expression}\n {callback_call}");
// Pre-task wrapper for Swift 6 sendability
let pre_task_bindings =
"let __callbacks = __private__UncheckedSendable((callbackWrapper, callback))";
// Destructure at start of Task body
let task_body = format!("let (callbackWrapper, callback) = __callbacks.value\n {result_binding}await {call_expression}\n {callback_call}");

(all_params.join(", "), task_body, None)
(
all_params.join(", "),
task_body,
None,
pre_task_bindings.to_string(),
)
};

let maybe_typed_throws_check = typed_throws_check.unwrap_or_default();

// Check if this is a method (has associated_type) - if so, we need to include 'this' in the wrapper
let (pre_task_bindings, task_body) = if func.associated_type.is_some() {
// For methods, we need to wrap 'this' as well
let is_result = maybe_result.is_some();
if is_result {
// Result case: wrap callbackWrapper, onSuccess, onError, this
let wrapper = "let __captures = __private__UncheckedSendable((callbackWrapper, onSuccess, onError, this))";
let destructure =
"let (callbackWrapper, onSuccess, onError, this) = __captures.value\n ";
(
wrapper.to_string(),
destructure.to_string()
+ &task_body.trim_start().replace(
"let (callbackWrapper, onSuccess, onError) = __callbacks.value\n ",
"",
),
)
} else {
// Non-result case: wrap callbackWrapper, callback, this
let wrapper =
"let __captures = __private__UncheckedSendable((callbackWrapper, callback, this))";
let destructure = "let (callbackWrapper, callback, this) = __captures.value\n ";
(
wrapper.to_string(),
destructure.to_string()
+ &task_body.trim_start().replace(
"let (callbackWrapper, callback) = __callbacks.value\n ",
"",
),
)
}
} else {
(pre_task_bindings.clone(), task_body.clone())
};

format!(
r#"@_cdecl("{link_name}")
func {prefixed_fn_name} ({params_str}) {{
{pre_task_bindings}
Task {{
{task_body}
}}
Expand Down
10 changes: 9 additions & 1 deletion test-swift-rust-integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ touch ./Generated/SwiftBridgeCore.{h,swift}
mkdir -p ./Generated/swift-integration-tests
touch ./Generated/swift-integration-tests/swift-integration-tests.{h,swift}

# Build settings override (e.g., SWIFT_VERSION=6.0)
EXTRA_BUILD_SETTINGS=""
if [ -n "$SWIFT_VERSION" ]; then
EXTRA_BUILD_SETTINGS="SWIFT_VERSION=$SWIFT_VERSION"
echo "Using Swift version: $SWIFT_VERSION"
fi

xcodebuild \
-project SwiftRustIntegrationTestRunner.xcodeproj \
-scheme SwiftRustIntegrationTestRunner \
clean test
clean test \
$EXTRA_BUILD_SETTINGS
Loading