Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
71b05be
chore(quest): claim quest/m1/ffi-export/swift-noncopyable.md
kixelated Nov 10, 2026
bbfbe4b
feat(ffi)!: make Swift handles noncopyable structs
kixelated Nov 10, 2026
da97005
style: fmt
kixelated Nov 10, 2026
5ba4b26
feat(ffi): freeze Swift's tuple structs, and adapt moq's Swift to non…
kixelated Nov 10, 2026
76f1256
Merge origin/main into quest/m1/ffi-export/swift-noncopyable
kixelated Nov 10, 2026
fc5fb3f
feat(ffi)!: label Swift's optional borrowed objects, so their overloa…
kixelated Nov 10, 2026
e8c8535
fix(ffi): refuse Swift's optional borrowed objects past the bound at …
kixelated Nov 10, 2026
19c657e
fix(ffi): borrow a struct's list and option in Swift's cleanup, and r…
kixelated Nov 10, 2026
54adcad
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export/s…
kixelated Nov 11, 2026
344a545
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export/s…
kixelated Nov 11, 2026
16ed917
Merge commit '344a5455' into quest/m1/ffi-export/swift-noncopyable
kixelated Nov 11, 2026
046347d
test(ffi): bind main's Batch as var for its &mut calls in Swift
kixelated Nov 11, 2026
66705d6
chore(quest): finish quest/m1/ffi-export/swift-noncopyable.md
kixelated Nov 11, 2026
8d574b7
test(ffi): trap on a Swift class used after Rust moves it out
kixelated Nov 11, 2026
6d0789c
Merge origin/main into quest/m1/ffi-export/swift-noncopyable
kixelated Nov 11, 2026
9d585d8
fix(ffi): name every Swift tuple struct before writing any
kixelated Nov 11, 2026
e194761
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Nov 11, 2026
e118ff8
test(ffi): move main's new Swift api cases onto noncopyable handles
kixelated Nov 11, 2026
22da6f5
merge: reconcile Swift ownership export with base targets and arrays
Nov 11, 2026
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
13 changes: 9 additions & 4 deletions bench/ffi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,19 +155,24 @@ frame path:
C ABI's borrow of each handle, a compare-and-swap in and a `fetch_sub` out of its
state, is the whole call. Every handle argument pays it again.
7. **The binding's own call.** Over C's getter: Swift adds 8 ns (its hold count's two
atomics), Kotlin 14 wall (JNI and its hold count), Dart 19 (an `_Arena` per call and
atomics, since dropped from all but its classes, as below), Kotlin 14 wall (JNI and its hold count), Dart 19 (an `_Arena` per call and
its hold), Go 31 (cgo, `defer`, and its hold), and Python 37 (argument parsing and the
GIL released and taken back, so Rust may call Python from another thread). BoltFFI's
Python getter, which holds the GIL, measured 63 ns.

### Per-handle baseline

For [checks-raise](/quest/m1/ffi-export/checks-raise.md)'s one accounting and
[swift-noncopyable](/quest/m1/ffi-export/swift-noncopyable.md)'s hold count: the u64
For [checks-raise](/quest/m1/ffi-export/checks-raise.md)'s one accounting: the u64
getter is one borrowed handle and nothing else, so its row less Rust's 0.4 ns is what a
handle costs a call. C, C++: 10 ns, the runtime's two atomics. Swift: 18, its hold count
adding two more. Kotlin: 24 wall, Go 41, Dart 29, Python 47, each with its call's own
transition besides. A change to the accounting moves this row and the handle call's by the
transition besides.

Swift has since made each object a noncopyable struct and dropped its hold count, but for
a class: a `Copy` object or one thrown as an error. Measured 2026-11-11 at that change, 5
rounds interleaved with C at a load average of 7 to 12, its getter costs 10 ns, as C's
does, and `track.is_closed()`, on `TrackProducer`, still a class since moq throws it, 26
against C's 18, as in the table. A change to the accounting moves this row and the handle call's by the
same amount; measure both, CPU first, at the load beside them.

### Against the targets
Expand Down
42 changes: 29 additions & 13 deletions ffi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,22 +484,35 @@ caller imports it with no ignores.
the header as the clang module `<Prefix>FFI`, which `swift/module.modulemap` declares
beside `swift/<prefix>_atomics.h`, the atomics its runtime needs that Swift's standard
library has only from macOS 15: a build puts `swift/` on its import path and links the
static library. It needs Swift 6 and, for `Swift.Duration`, macOS 13 or iOS 16, and builds
in either language mode.
static library. It imports swift-collections' `BasicContainers` for `UniqueArray`, which
`just ffi swift-collections` builds at the version the tests pin, 1.7.5. It needs Swift 6.2
and, for `Swift.Duration`, macOS 13 or iOS 16, and builds in either language mode.

- **Names** are as Kotlin's, at the module's top level: types in PascalCase without the
prefix, members in camelCase without their type's name (`TrackProducer.appendGroup`). A
keyword is quoted in backticks, and a member named as one Swift gives its type, such as
`init`, or as a field or member before it, takes a trailing `_`. Names that clash are
kept apart as Kotlin's are. Parameters have no labels, so a call reads as Rust's, and a
kept apart as Kotlin's are. Parameters have no labels but an optional borrowed object's,
so a call reads as Rust's, and a
constructor such as `new` stays a static function: `Counter.new(1)`.
- **Objects** are final classes owning their handle, freed in `deinit`, which a call
holds off until it returns, so there is no `close()`. A `self` method, or passing the
object by value, moves its value out and closes it once the call returns, after which a
call traps. `Clone` is `clone()`, and `PartialEq`
is `==`. A field is a computed property, `firstGet`, and its setter a function,
`firstSet(value)`, named as the C ones, since setting an object moves it in and closes
its handle, which an assignment would hide. An object is `Sendable`, since Rust checks each borrow at run time.
- **Objects** are noncopyable structs (`~Copyable`) owning their handle, freed in `deinit`,
so there is no `close()`, and Swift's compiler checks each move and borrow Rust makes: a
`self` method is `consuming`, a `&mut self` one `mutating`, so its object must be a
`var`, and a parameter is `consuming` by value, `borrowing` for `&T`, and `inout` for
`&mut T`. A call on a moved object, or an object lent to a closure kept past its call,
fails to compile. A tuple holding one is a generated struct, `TupleStringCounter`, whose
fields `_0`, `_1`, ... are its elements, and an array of them swift-collections'
`UniqueArray`. Since an optional holds one only by consuming it, an `Option<&T>` of one
is an overload with and without it, labelled by its name, as a defaulted argument is:
`clamped()`, `clamped(low:)`, `clamped(high:)` and `clamped(low:high:)`, up to two per
function; `Option<&mut T>` is `inout T?`. `Clone` is `clone()`, and `PartialEq` is `==`, which Swift 6.4 makes
`Equatable`; `Display` is `description` and `Debug` `debugDescription`, which Swift
conforms no noncopyable type to yet. An object that is `Copy`, or thrown as an error,
which `Swift.Error` requires be copyable, is a final class instead, whose handle a call
holds until it returns, and moving its value out closes it, after which a call traps. A
field is a computed property, `firstGet`, and its setter a function, `firstSet(value)`,
named as the C ones, since setting an object moves it in, which an assignment would hide.
An object is `Sendable`, since Rust checks each borrow at run time.
- **Records** are structs and **enums** Swift enums, with a variant's data as associated
values, all `Sendable`. `PartialEq` is `==`, through Rust's; `Display` is `description`,
`Debug` `debugDescription`, and `Default` a static `default()`.
Expand All @@ -517,10 +530,12 @@ in either language mode.
- A **path** is a `String`: one Rust returns that is not UTF-8 traps. A `SocketAddr`,
`SocketAddrV4`, or `SocketAddrV6` is the runtime's `SocketAddr`, its IP's 4 or 16 bytes
and its port, flow label, and scope, whose family Rust checks. A `Vec<T>` or `&[T]` is
an array, and passing a `Vec` by value closes each object in it once the call returns.
an array, or a `UniqueArray` of objects.
- **Traits** are `Sendable` protocols, which Rust releases once it drops an implementation.
- **Closures** lent for a call are `@Sendable` function types, which Rust calls only during
the call, from any thread. An object it lends is closed once the call returns.
the call, from any thread. An object it lends is `borrowing`, or `inout` for `&mut T`,
so it cannot outlive the call; assigning another to the `inout` one traps, since Rust's
stays its own.
- **Futures** are `async throws` functions, which resume on the task's executor, never
inside a wake. Cancelling the task frees the future, losing nothing, and throws
`CancellationError`. Each call is its own future, so it has one poller. The call polls it
Expand Down Expand Up @@ -781,7 +796,8 @@ abort with its message; the Python with another contract must refuse the library
`just ffi moq` checks moq's.
`tests/api.swift` runs the script through the Swift, built as a module in Swift 6's
language mode and type-checked in Swift 5's, checks its futures the same way and that
releasing frees, and breaks each runtime check, which must trap or abort with its message.
releasing frees, and breaks each runtime check, which must trap or abort with its message,
and each move or borrow the compiler checks, which must fail to compile.
It runs with nixpkgs' Swift on Linux, which builds none for aarch64-linux
([quest](/quest/m2/swift-arm.md)), and nightly with Apple's on macOS.
`tests/api.go` runs the script through the Go after `go vet`, checks its futures the same
Expand Down
17 changes: 17 additions & 0 deletions ffi/fixtures/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ impl Counter {
self.value + other.map_or(0, |other| other.value)
}

/// This count, raised to `low`'s and lowered to `high`'s, each if any: two optional
/// handles, lent shared.
pub fn clamped(&self, low: Option<&Counter>, high: Option<&Counter>) -> u64 {
let low = low.map_or(self.value, |low| self.value.max(low.value));
high.map_or(low, |high| low.min(high.value))
}

/// [`Self::plus`], once [`yielded`] finishes: a future borrowing both handles meanwhile.
pub async fn plus_later(&self, other: Option<&Counter>) -> u64 {
yielded(0).await;
Expand Down Expand Up @@ -2304,6 +2311,16 @@ impl fmt::Display for Refusal {
}
}

/// Each counter's name with its refusal, `last`'s too if any: an object Swift holds as a
/// struct beside one it holds as a class, moved in inside a list and an option.
pub fn reasons(pairs: Vec<(Counter, Refusal)>, last: Option<(Counter, Refusal)>) -> String {
let pairs = pairs.iter().chain(&last);
pairs
.map(|(counter, refusal)| format!("{} {refusal}", counter.name))
.collect::<Vec<_>>()
.join(", ")
}

/// Always refuses, for `reason`.
pub fn refuse(reason: &str) -> Result<u32, Box<Refusal>> {
Err(Box::new(Refusal {
Expand Down
6 changes: 4 additions & 2 deletions ffi/fixtures/reject/expected.txt
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,7 @@ fixtures/reject/src/lib.rs:855:9: rust2-ffi: cannot export use `renamed`: re-exp
fixtures/reject/src/lib.rs:866:2: rust2-ffi: cannot export method `Tally::poll_bump`: `&mut self` of a value; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:886:1: rust2-ffi: cannot export fn `poll_risky`: Poll with no Context or waiter parameter `std::task::Poll<()>`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:902:1: rust2-ffi: cannot export fn `poll_loose`: Poll with no Context or waiter parameter `std::task::Poll<()>`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:908:1: rust2-ffi: cannot export fn `sum_pair`: array of an element other than u8 `[u16; 2]`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:913:1: rust2-ffi: cannot export fn `empty`: empty array `[u8; 0]`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:909:1: rust2-ffi: cannot export fn `among`: 3 optional borrowed objects, past the 2 Swift takes, which overloads each with and without it since its optional holds an object only by consuming it; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:915:1: rust2-ffi: cannot export fn `wrapped`: error holding an object Swift cannot copy, as `Swift.Error` requires; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:920:1: rust2-ffi: cannot export fn `sum_pair`: array of an element other than u8 `[u16; 2]`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:925:1: rust2-ffi: cannot export fn `empty`: empty array `[u8; 0]`; exclude it in the config to leave it out
12 changes: 12 additions & 0 deletions ffi/fixtures/reject/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,18 @@ pub fn poll_loose(waiter: &Loose) -> std::task::Poll<()> {
std::task::Poll::Ready(())
}

/// Three optional borrowed objects: Swift, whose optional holds an object only by consuming
/// it, would overload each with and without it, past the bound it takes.
pub fn among(first: Option<&Opaque>, second: Option<&Opaque>, third: Option<&Opaque>) -> u8 {
[first, second, third].into_iter().flatten().map(|o| o.0).sum()
}

/// An error holding an object, which Swift holds as a noncopyable struct while
/// `Swift.Error` requires a copyable type.
pub fn wrapped(value: u8) -> Result<u8, Vec<Opaque>> {
Err(vec![Opaque(value)])
}

/// An array of an element other than `u8`, which no export needs yet.
pub fn sum_pair(pair: [u16; 2]) -> u32 {
u32::from(pair[0]) + u32::from(pair[1])
Expand Down
40 changes: 36 additions & 4 deletions ffi/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export PATH := if env("RUST2_TS_TOOLCHAIN", "") == "" { env("PATH") } else { car
# just's own path is quoted wherever bash runs it: on Windows, its backslashes are escapes.
moq_rev := shell(quote(just_executable()) + " --justfile ../ts/justfile --evaluate moq_rev")

# swift-collections 1.7.5, whose `UniqueArray` the generated Swift imports.
swift_collections_rev := "f96b909e1a9e469620bc5037c1e24e00153f376f"

default:
@just --list

Expand Down Expand Up @@ -82,7 +85,7 @@ bench-build checkout="": (moq-export checkout)
rm -f target/bench-export-swift
else
{{ quote(just_executable()) }} swift-module -O
swiftc -O -warnings-as-errors $({{ quote(just_executable()) }} swift-target 15) -I target/swift -I "$out/swift" moq/bench/export.swift target/swift/libMoq.a "$out/target/release/libmoq.a" -lpthread -ldl -lm -o target/bench-export-swift
swiftc -O -warnings-as-errors $({{ quote(just_executable()) }} swift-target 15) -I target/swift -I target/swift-collections/build -I "$out/swift" moq/bench/export.swift target/swift/libMoq.a -L target/swift-collections/build -lBasicContainers -lInternalCollectionsUtilities -lSpanPreview "$out/target/release/libmoq.a" -lpthread -ldl -lm -o target/bench-export-swift
fi

# Run what bench-build built: each program `rounds` times into target/bench/raw.tsv, with the 1-minute load average over each run, and their medians and the export's sizes into target/bench/report.md.
Expand Down Expand Up @@ -185,18 +188,47 @@ moq-swift-run:
out=target/export/moq
read -ra native < target/moq-native
{{ quote(just_executable()) }} swift-module
swiftc -warnings-as-errors $({{ quote(just_executable()) }} swift-target 15) -parse-as-library -I target/swift -I "$out/swift" moq/moq.swift target/swift/libMoq.a "$out/target/release/libmoq.a" "${native[@]}" -o target/moq-swift-test
swiftc -warnings-as-errors $({{ quote(just_executable()) }} swift-target 15) -parse-as-library -I target/swift -I target/swift-collections/build -I "$out/swift" moq/moq.swift target/swift/libMoq.a -L target/swift-collections/build -lBasicContainers -lInternalCollectionsUtilities -lSpanPreview "$out/target/release/libmoq.a" "${native[@]}" -o target/moq-swift-test
target/moq-swift-test

# Build target/export/moq's Swift as the module Moq in target/swift, at Swift 6's language mode. Its deployment target is the oldest its `Swift.Duration` allows.
[private]
swift-module *flags:
swift-module *flags: swift-collections
#!/usr/bin/env bash
set -euo pipefail
out=target/export/moq/swift
rm -rf target/swift
mkdir -p target/swift
swiftc -warnings-as-errors $({{ quote(just_executable()) }} swift-target 13) -swift-version 6 {{ flags }} -parse-as-library -module-name Moq -emit-module -emit-module-path target/swift/Moq.swiftmodule -emit-library -static -I "$out" "$out/moq.swift" -o target/swift/libMoq.a
swiftc -warnings-as-errors $({{ quote(just_executable()) }} swift-target 13) -swift-version 6 {{ flags }} -parse-as-library -module-name Moq -emit-module -emit-module-path target/swift/Moq.swiftmodule -emit-library -static -I "$out" -I target/swift-collections/build "$out/moq.swift" -o target/swift/libMoq.a

# Build swift-collections' BasicContainers, which the generated Swift imports, and the modules it imports, at the pinned revision with Swift on PATH, into target/swift-collections/build: a module and a static library each, as its package's manifest for Swift 6.2 builds them.
[private]
swift-collections:
#!/usr/bin/env bash
set -euo pipefail
src=target/swift-collections/src
out=target/swift-collections/build
[ -d "$src/.git" ] || git clone --quiet --filter=blob:none --no-checkout https://github.com/apple/swift-collections "$src"
GIT_NO_LAZY_FETCH=1 git -C "$src" cat-file -e {{ swift_collections_rev }}^{commit} 2> /dev/null || git -C "$src" fetch --quiet origin {{ swift_collections_rev }}
git -C "$src" checkout --quiet --detach {{ swift_collections_rev }}
# Built once for each revision and compiler.
stamp="{{ swift_collections_rev }} $(swiftc --version 2>&1)"
[ "$(cat "$out/stamp" 2> /dev/null)" != "$stamp" ] || exit 0
rm -rf "$out"
mkdir -p "$out"
flags=(-swift-version 6 -O -parse-as-library -package-name swift_collections -I "$out" $({{ quote(just_executable()) }} swift-target 13)
-enable-upcoming-feature MemberImportVisibility -enable-experimental-feature BuiltinModule
-enable-experimental-feature Lifetimes -enable-experimental-feature InoutLifetimeDependence
-enable-experimental-feature AddressableParameters -enable-experimental-feature AddressableTypes)
while IFS= read -r macro; do
flags+=(-enable-experimental-feature "AvailabilityMacro=$macro")
done < <(sed -nE 's/^ *"(SwiftStdlib [0-9.]+)": *"([^"]+)",?$/\1: \2/p' "$src/Package@swift-6.2.swift")
for module in InternalCollectionsUtilities SpanPreview BasicContainers; do
files=()
while IFS= read -r -d '' file; do files+=("$file"); done < <(find "$src/Sources/$module" -name '*.swift' -print0 | sort -z)
swiftc "${flags[@]}" -module-name "$module" -emit-module -emit-module-path "$out/$module.swiftmodule" -emit-library -static -o "$out/lib$module.a" "${files[@]}"
done
echo "$stamp" > "$out/stamp"

# swiftc's target at this macOS deployment target on macOS, and nothing elsewhere.
[private]
Expand Down
Loading