From 71b05be745ead5bc2c0734aecc92655fb00baec9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 10:30:49 -0800 Subject: [PATCH 01/12] chore(quest): claim quest/m1/ffi-export/swift-noncopyable.md Quest-Claim: 48cd11df7931dbd8 From bbfbe4ba39eb36eb5f26bbcf0cc496d957181d30 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 12:26:05 -0800 Subject: [PATCH 02/12] feat(ffi)!: make Swift handles noncopyable structs Co-Authored-By: Claude Opus 5.5 --- ffi/README.md | 36 +++-- ffi/justfile | 40 +++++- ffi/runtime/rt.swift | 82 ++++++++--- ffi/src/swift.rs | 315 ++++++++++++++++++++++++++++++++++++------ ffi/tests/api.swift | 152 ++++++++++++-------- ffi/tests/fixtures.rs | 62 +++++++-- 6 files changed, 540 insertions(+), 147 deletions(-) diff --git a/ffi/README.md b/ffi/README.md index b0a8098bd..7fc6dc0c7 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -387,21 +387,30 @@ caller imports it with no ignores. the header as the clang module `FFI`, which `swift/module.modulemap` declares beside `swift/_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`, takes a trailing `_`. Parameters have no labels, 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`. `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()`. @@ -415,10 +424,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` 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 @@ -667,7 +678,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 diff --git a/ffi/justfile b/ffi/justfile index 0c5711b03..c71319e91 100644 --- a/ffi/justfile +++ b/ffi/justfile @@ -12,6 +12,9 @@ export PATH := if env("RUST2_TS_TOOLCHAIN", "") == "" { env("PATH") } else { env # 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 @@ -72,7 +75,7 @@ bench checkout="": (moq checkout) swift=false 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 for _ in 1 2 3; do target/bench-moq-c @@ -143,18 +146,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] diff --git a/ffi/runtime/rt.swift b/ffi/runtime/rt.swift index fa23883d4..a26c3dd96 100644 --- a/ffi/runtime/rt.swift +++ b/ffi/runtime/rt.swift @@ -1,6 +1,7 @@ // The runtime every generated Swift file includes, after `Native` names the export's C -// types and functions: the handles objects own, the arena a call lowers its values into, -// bytes and text across the C ABI, and the signal a future parks on. +// types and functions: the handles classes own, the arena a call lowers its values into, +// bytes and text across the C ABI, and the signal a future parks on. Each helper that +// returns what its body does takes a noncopyable result, an object's struct. /// An IP socket address, as Rust's `SocketAddr` holds it. public struct SocketAddr: Hashable, Sendable { @@ -44,32 +45,37 @@ extension Native { static var none: Buf { Buf(ptr: nil, len: 0, owner: nil) } - /// `bytes` as a view C reads during `body`, without a copy. + /// `bytes` as a view C reads during `body`, without a copy. Foundation's closure + /// returns only a copyable result, so `body`'s leaves through an optional. @inline(__always) - static func view(_ bytes: Data, _ body: (Buf) throws -> R) rethrows -> R { + static func view(_ bytes: Data, _ body: (Buf) throws -> R) rethrows -> R { + var result: R? = nil try bytes.withUnsafeBytes { raw in let ptr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) ?? UnsafePointer(empty) - return try body(Buf(ptr: ptr, len: raw.count, owner: nil)) + result = try body(Buf(ptr: ptr, len: raw.count, owner: nil)) } + return result.take()! } @inline(__always) - static func view(_ bytes: Data?, _ body: (Buf) throws -> R) rethrows -> R { + static func view(_ bytes: Data?, _ body: (Buf) throws -> R) rethrows -> R { guard let bytes else { return try body(none) } return try view(bytes, body) } /// `text`'s UTF-8 as a view C reads during `body`, without a copy for a native string. @inline(__always) - static func view(_ text: Swift.String, _ body: (Buf) throws -> R) rethrows -> R { + static func view(_ text: Swift.String, _ body: (Buf) throws -> R) rethrows -> R { var text = text - return try text.withUTF8 { utf8 in - try body(Buf(ptr: utf8.baseAddress ?? UnsafePointer(empty), len: utf8.count, owner: nil)) + var result: R? = nil + try text.withUTF8 { utf8 in + result = try body(Buf(ptr: utf8.baseAddress ?? UnsafePointer(empty), len: utf8.count, owner: nil)) } + return result.take()! } @inline(__always) - static func view(_ text: Swift.String?, _ body: (Buf) throws -> R) rethrows -> R { + static func view(_ text: Swift.String?, _ body: (Buf) throws -> R) rethrows -> R { guard let text else { return try body(none) } return try view(text, body) } @@ -211,9 +217,31 @@ extension Native { return try UnsafeBufferPointer(start: ptr, count: len).map(lift) } - /// A handle an object owns, or is lent for a call: each call holds it, so `close`, - /// once a call moves its value out or a lend ends, frees it only once no call does, and - /// a call after `close` traps. + /// As `list`, of items Swift cannot copy. + static func unique( + _ ptr: UnsafePointer?, _ len: Int, _ lift: (C) throws -> T + ) rethrows -> UniqueArray { + var items = UniqueArray(minimumCapacity: len) + for i in 0..(_ value: borrowing T?, _ some: (borrowing T) throws -> C, _ none: C) rethrows -> C { + switch value { + case .some(let value): return try some(value) + case .none: return none + } + } + + /// A handle a class owns, or is lent for a call: each call holds it, so `close`, once + /// a call moves its value out or a lend ends, frees it only once no call does, and a + /// call after `close` traps. A struct needs none of this: Swift's compiler orders every + /// call before its move or the end of its lend. struct Handle { let raw: OpaquePointer? /// The holds, one for the owner until closed and one per call, shifted past a bit @@ -256,7 +284,7 @@ extension Native { /// Run `body` with the raw handle, held until it returns. @inline(__always) - func with(_ body: (OpaquePointer?) throws -> R) rethrows -> R { + func with(_ body: (OpaquePointer?) throws -> R) rethrows -> R { let raw = hold() defer { release() } return try body(raw) @@ -295,7 +323,7 @@ extension Native { /// Run `body` with the raw handle of `handle`, or null for none, held until it returns. @inline(__always) - static func with(_ handle: Handle?, _ body: (OpaquePointer?) throws -> R) rethrows -> R { + static func with(_ handle: Handle?, _ body: (OpaquePointer?) throws -> R) rethrows -> R { guard let handle else { return try body(nil) } return try handle.with(body) } @@ -354,6 +382,18 @@ extension Native { return (UnsafePointer(array), items.count) } + /// As `list`, of items Swift cannot copy, each borrowed. + func unique( + _ items: borrowing UniqueArray, _ lower: (borrowing T) throws -> C + ) rethrows -> (UnsafePointer, Int) { + let array = UnsafeMutablePointer.allocate(capacity: Swift.max(items.count, 1)) + blocks.append(UnsafeMutableRawPointer(array)) + for i in 0..( + static func run( _ raw: OpaquePointer?, _ free: @convention(c) (OpaquePointer?) -> Void, _ poll: (OpaquePointer?, CWaker) throws -> T? ) async throws -> T { @@ -477,12 +517,15 @@ extension Native { } let signal = Signal() var given = false - return try await withTaskCancellationHandler { + // The handler's operation returns only a copyable result, so it leaves through this. + var output: T? = nil + try await withTaskCancellationHandler { while true { signal.reset() try Task.checkCancellation() - if let output = try poll(raw, signal.waker(given: !given)) { - return output + if let ready = try poll(raw, signal.waker(given: !given)) { + output = consume ready + return } given = true await signal.park() @@ -490,5 +533,6 @@ extension Native { } onCancel: { signal.wake() } + return output.take()! } } diff --git a/ffi/src/swift.rs b/ffi/src/swift.rs index da4d491f9..65435a36d 100644 --- a/ffi/src/swift.rs +++ b/ffi/src/swift.rs @@ -4,12 +4,17 @@ //! Each name is its C name without the prefix, as Swift spells it: `moq_track_producer` //! is `TrackProducer`, and its method `moq_track_producer_append_group` is //! `TrackProducer.appendGroup`. A keyword is quoted in backticks, and a member named as one -//! Swift gives its type takes a trailing `_`. An object is a final class owning its handle, -//! freed in `deinit`; a record a struct; an enum a Swift enum, with +//! Swift gives its type takes a trailing `_`. An object is a noncopyable struct owning its +//! handle, freed in `deinit`, so Swift's compiler checks each move and borrow Rust makes: +//! `self` is `consuming`, `&mut self` `mutating`, a parameter `consuming`, `borrowing`, or +//! `inout`, a tuple holding one a struct of its own, and an array of them swift-collections' +//! `UniqueArray`. An object that is `Copy` or thrown as an error is a final class instead, +//! since `Swift.Error` requires a copyable type, whose handle a call holds at run time. A +//! record is a struct; an enum a Swift enum, with //! its data as associated values; a trait a protocol; a closure a function type; a future //! an `async` function; an error a thrown `Swift.Error`. The runtime they share, -//! [`runtime/rt.swift`](../runtime/rt.swift), holds the handles, the arena a call lowers -//! nested values into, and the signal a future parks on. +//! [`runtime/rt.swift`](../runtime/rt.swift), holds the classes' handles, the arena a call +//! lowers nested values into, and the signal a future parks on. //! //! A call passes bytes and text at its top level as views of Swift's own memory, and //! returned bytes are `Data` lending Rust's buffer, so neither is copied; nested in another @@ -19,7 +24,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Write; use crate::abi::{self, Abi, Ty, pascal}; -use crate::ir::{Access, Function, Int, Literal, Param, Receiver, Type}; +use crate::ir::{Access, Function, Int, Literal, Mutability, Param, Receiver, Type}; const RUNTIME: &str = include_str!("../runtime/rt.swift"); @@ -49,6 +54,7 @@ pub fn generate(abi: &Abi<'_>, packages: &str, contract: u64) -> Result, packages: &str, contract: u64) -> Result { thrown: BTreeMap, /// The exported types thrown as errors, which conform to `Swift.Error`. conforms: BTreeSet, + /// The objects that are noncopyable structs: each but those `Copy` or thrown. + structs: BTreeSet, fresh: usize, } @@ -224,6 +233,48 @@ impl<'a> Gen<'a> { } } + /// Whether Swift's value of `ty` is noncopyable: it holds an object's struct. + fn unique(&self, ty: &Type) -> bool { + match ty { + Type::Object(i) => self.structs.contains(i), + Type::Option(inner) | Type::Box(inner) | Type::Ref(_, inner) | Type::List(_, inner) => self.unique(inner), + Type::Tuple(elements) => elements.iter().any(|e| self.unique(e)), + _ => false, + } + } + + /// The ownership a parameter of type `ty` declares, which Swift requires of a + /// noncopyable one: `&T` borrows it, `&mut T` is `inout`, and a value moves in. + fn ownership(&self, ty: &Type) -> &'static str { + match ty { + _ if !self.unique(ty) => "", + Type::Ref(Mutability::Not, _) => "borrowing ", + Type::Ref(Mutability::Mut, _) => "inout ", + _ => "consuming ", + } + } + + /// The Swift value of the `n`th element of the tuple `e` of `elements`: a struct's + /// field `_n` if it is noncopyable, else the tuple's element. + fn element(&self, elements: &[Type], e: &str, n: usize) -> String { + match elements.iter().any(|e| self.unique(e)) { + true => format!("{e}._{n}"), + false => format!("{e}.{n}"), + } + } + + /// Whether passing `ty` moves the value of a class's handle out, so the call closes it: + /// by value, boxed, or in an `Option`, a list, or a tuple, unless `Copy`, and never + /// through a reference. A struct needs no close: Swift's compiler ends it. + fn moved(&self, ty: &Type) -> bool { + match ty { + Type::Object(i) => !self.abi.api.objects[*i].copy && !self.structs.contains(i), + Type::Option(inner) | Type::Box(inner) | Type::List(_, inner) => self.moved(inner), + Type::Tuple(elements) => elements.iter().any(|ty| self.moved(ty)), + _ => false, + } + } + /// The Swift type of `ty`, as a value, a parameter, or a result. fn sw(&self, ty: &Type) -> String { let abi = self.abi; @@ -238,6 +289,7 @@ impl<'a> Gen<'a> { Type::Slice | Type::Vec | Type::Bytes => "Data".to_string(), Type::Duration => "Swift.Duration".to_string(), Type::SocketAddr(_) => "SocketAddr".to_string(), + Type::List(_, inner) if self.unique(inner) => format!("UniqueArray<{}>", self.sw(inner)), Type::List(_, inner) => format!("[{}]", self.sw(inner)), Type::Waker => "@escaping @Sendable () -> Void".to_string(), Type::Option(inner) => format!("{}?", self.sw(inner)), @@ -247,13 +299,16 @@ impl<'a> Gen<'a> { Type::Object(i) => self.class(&abi.object(*i)), Type::Callback(i, _) => format!("any {}", self.class(&abi.callback(*i))), Type::Closure(params, ret) => { - let params: Vec = params.iter().map(|p| self.sw(p)).collect(); + let params: Vec = (params.iter()) + .map(|p| format!("{}{}", self.ownership(p), self.sw(p))) + .collect(); format!("@Sendable ({}) -> {}", params.join(", "), self.sw(ret)) } Type::Range(_) | Type::Bound(_) | Type::Bounds(_) => self.class(&abi.range(ty)), Type::Tuple(elements) => match &elements[..] { // Swift has no tuple of one: it is its element, which loses nothing. [element] => self.sw(element), + elements if self.unique(ty) => self.class(&abi.tuple(elements)), elements => { let elements: Vec = elements.iter().map(|e| self.sw(e)).collect(); format!("({})", elements.join(", ")) @@ -316,7 +371,8 @@ impl<'a> Gen<'a> { } /// The C value of the Swift value `e` of type `ty`, nested in a value the call passes: - /// bytes copied into the arena `a_`, and handles held by it. + /// bytes copied into the arena `a_`, and a class's handle held by it. A struct's handle + /// needs no hold, since the call borrows or consumes the struct until it returns. fn lower(&mut self, ty: &Type, e: &str) -> String { let abi = self.abi; if Abi::is_buf(ty) { @@ -334,11 +390,28 @@ impl<'a> Gen<'a> { Type::List(_, inner) => { let x = self.fresh("x"); let item = self.lower(inner, &x); + let list = match self.unique(inner) { + true => "unique", + false => "list", + }; format!( - "Native.vec(a_.list({e}) {{ {x} in {item} }}, {}.init(ptr:len:owner:))", + "Native.vec(a_.{list}({e}) {{ {x} in {item} }}, {}.init(ptr:len:owner:))", abi.list(inner) ) } + // `Optional.map` takes only a copyable value: `Native.option` borrows it. + Type::Option(inner) if self.unique(inner) => { + let v = self.fresh("v"); + let (some, none) = match inner.base() { + Type::Object(_) => (format!("{v}.handle"), "nil".to_string()), + Type::List(_, elem) => (self.lower(inner, &v), format!("{}()", abi.list(elem))), + _ => { + let c = abi.option(inner); + (format!("{c}(some: true, value: {})", self.lower(inner, &v)), format!("{c}()")) + } + }; + format!("Native.option({e}, {{ {v} in {some} }}, {none})") + } Type::Option(inner) => match inner.base() { Type::Object(_) => format!("a_.hold({e}?.handle)"), _ if Abi::is_buf(inner) => format!("a_.buf({e})"), @@ -356,6 +429,7 @@ impl<'a> Gen<'a> { }, Type::Box(inner) | Type::Ref(_, inner) => self.lower(inner, e), Type::Record(_) | Type::Enum(_) => format!("{e}._lower({})", self.arena_arg(ty)), + Type::Object(_) if self.unique(ty) => format!("{e}.handle"), Type::Object(_) => format!("a_.hold({e}.handle)"), Type::Callback(i, _) => format!("Native.lower_{}({e})", abi.callback(*i)), Type::Tuple(elements) => { @@ -363,7 +437,7 @@ impl<'a> Gen<'a> { let fields: Vec = match &elements[..] { [element] => vec![format!("_0: {}", self.lower(element, e))], elements => (elements.iter().enumerate()) - .map(|(n, ty)| format!("_{n}: {}", self.lower(ty, &format!("{e}.{n}")))) + .map(|(n, ty)| format!("_{n}: {}", self.lower(ty, &self.element(elements, e, n)))) .collect(), }; format!("{c}({})", fields.join(", ")) @@ -390,8 +464,12 @@ impl<'a> Gen<'a> { Type::List(_, inner) => { let (v, c) = (self.fresh("v"), self.fresh("c")); let item = self.lift(inner, &c); + let list = match self.unique(inner) { + true => "unique", + false => "list", + }; format!( - "{{ () -> {} in var {v} = {e}; defer {{ {v}.len = 0; {}_free({v}) }}; return Native.list({v}.ptr, {v}.len) {{ {c} in {item} }} }}()", + "{{ () -> {} in var {v} = {e}; defer {{ {v}.len = 0; {}_free({v}) }}; return Native.{list}({v}.ptr, {v}.len) {{ {c} in {item} }} }}()", self.sw(ty), abi.list(inner) ) @@ -421,7 +499,10 @@ impl<'a> Gen<'a> { let lifted: Vec = (elements.iter().enumerate()) .map(|(n, ty)| self.lift(ty, &format!("{e}._{n}"))) .collect(); - format!("({})", lifted.join(", ")) + match self.unique(ty) { + true => format!("{}({})", self.sw(ty), lifted.join(", ")), + false => format!("({})", lifted.join(", ")), + } } }, other => unreachable!("{other:?} is never returned"), @@ -452,6 +533,29 @@ impl<'a> Gen<'a> { for (c, ty) in &abi.ranges.clone() { self.range(c, ty); } + // Every tuple holding a struct, wherever it is, a whole return's included, which C + // returns through an out pointer per element rather than a struct. + let methods = (api.records.iter().flat_map(|r| &r.methods)) + .chain(api.enums.iter().flat_map(|e| &e.methods)) + .chain(api.objects.iter().flat_map(|o| &o.methods)); + let mut tuples = BTreeMap::new(); + for function in api.functions.iter().chain(methods) { + let types = (function.params.iter().map(|p| &p.ty)) + .chain([&function.ok]) + .chain(&function.err); + for ty in types { + crate::ir::each(&strip_refs(ty), &mut |ty| { + if let Type::Tuple(elements) = ty + && elements.len() > 1 && self.unique(ty) + { + tuples.insert(abi.tuple(elements), elements.clone()); + } + }); + } + } + for (c, elements) in &tuples { + self.tuple(c, elements); + } for constant in &api.constants { let name = self.bare(&abi.name(&constant.path)).to_uppercase(); self.claim(&name, format!("const `{}`", constant.path.join("::"))); @@ -497,6 +601,28 @@ impl<'a> Gen<'a> { } } + /// The struct of a tuple holding an object's struct, which a Swift tuple cannot hold: + /// its fields `_0`, `_1`, ... are the tuple's elements. + fn tuple(&mut self, c: &str, elements: &[Type]) { + let name = self.class(c); + let types: Vec = elements.iter().map(|e| self.sw(e)).collect(); + self.claim(&name, format!("the tuple `({})`", types.join(", "))); + let fields: String = (types.iter().enumerate()) + .map(|(n, sw)| format!("\tpublic var _{n}: {sw}\n")) + .collect(); + let params: Vec = (elements.iter().zip(&types).enumerate()) + .map(|(n, (e, sw))| format!("_ _{n}: {}{sw}", self.ownership(e))) + .collect(); + let assigns: String = (0..elements.len()).map(|n| format!("\t\tself._{n} = _{n}\n")).collect(); + let shown: Vec<&str> = types.iter().map(|t| t.trim_start_matches("Swift.")).collect(); + let _ = write!( + self.types, + "/// The tuple `({})`, as a struct, since it holds a value Swift cannot copy.\npublic struct {name}: ~Copyable, Sendable {{\n{fields}\n\tpublic init({}) {{\n{assigns}\t}}\n}}\n\n", + shown.join(", "), + params.join(", ") + ); + } + /// A struct for a range or pair of bounds, or an enum for a bound, and its conversions. fn range(&mut self, c: &str, ty: &Type) { let name = self.class(c); @@ -551,8 +677,10 @@ impl<'a> Gen<'a> { ) { let name = self.sw(ty); let object = matches!(ty, Type::Object(_)); - // The receiver: a held handle, or the value lowered. + let unique = self.unique(ty); + // The receiver: a struct's handle, a class's held, or the value lowered. let (open, this, close) = match object { + true if unique => (String::new(), "self.handle", ""), true => ("self.handle.with { h_ in ".to_string(), "h_", " }"), false => { let arena = match self.arena(ty) { @@ -591,13 +719,18 @@ impl<'a> Gen<'a> { } if object && t.clone { members.push("clone".to_string()); + let raw = match unique { + true => format!("{c}_clone(self.handle)"), + false => format!("self.handle.with {{ {c}_clone($0) }}"), + }; body.push(format!( - "\t/// A new handle to a clone.\n\tpublic func clone() -> {name} {{\n\t\t{name}(raw: self.handle.with {{ {c}_clone($0) }})\n\t}}\n" + "\t/// A new handle to a clone.\n\tpublic func clone() -> {name} {{\n\t\t{name}(raw: {raw})\n\t}}\n" )); } if t.eq { protocols.push("Equatable"); let compare = match object { + true if unique => format!("{c}_eq(a.handle, b.handle)"), true => format!("a.handle.with {{ a_ in b.handle.with {{ b_ in {c}_eq(a_, b_) }} }}"), false => { let arena = match self.arena(ty) { @@ -610,8 +743,12 @@ impl<'a> Gen<'a> { ) } }; + let borrowing = match unique { + true => "borrowing ", + false => "", + }; body.push(format!( - "\tpublic static func == (a: {name}, b: {name}) -> Swift.Bool {{\n\t\t{compare}\n\t}}\n" + "\tpublic static func == (a: {borrowing}{name}, b: {borrowing}{name}) -> Swift.Bool {{\n\t\t{compare}\n\t}}\n" )); } if t.default { @@ -823,10 +960,17 @@ impl<'a> Gen<'a> { let rust = abi.api.rust(&object.path, &object.args); self.claim(&name, format!("type `{rust}`")); let ty = Type::Object(i); + let unique = self.unique(&ty); let mut members = vec!["handle".to_string()]; - let mut body = vec![format!( - "\tlet handle: Native.Handle\n\n\tinit(raw: OpaquePointer?) {{\n\t\thandle = Native.Handle(raw, {c}_free, \"{name}\")\n\t}}\n\n\tinit(lent raw: OpaquePointer?) {{\n\t\thandle = Native.Handle(raw, nil, \"{name}\")\n\t}}\n\n\tdeinit {{\n\t\thandle.drop()\n\t}}\n" - )]; + let mut body = vec![match unique { + // A lend ends with `_end`, which leaves Rust the handle it lent to free. + true => format!( + "\tlet handle: OpaquePointer?\n\n\tinit(raw: OpaquePointer?) {{\n\t\thandle = raw\n\t}}\n\n\tdeinit {{\n\t\t{c}_free(handle)\n\t}}\n\n\tconsuming func _end() {{\n\t\tdiscard self\n\t}}\n" + ), + false => format!( + "\tlet handle: Native.Handle\n\n\tinit(raw: OpaquePointer?) {{\n\t\thandle = Native.Handle(raw, {c}_free, \"{name}\")\n\t}}\n\n\tinit(lent raw: OpaquePointer?) {{\n\t\thandle = Native.Handle(raw, nil, \"{name}\")\n\t}}\n\n\tdeinit {{\n\t\thandle.drop()\n\t}}\n" + ), + }]; let mut protocols = vec!["@unchecked Sendable"]; self.traits(&ty, &c, object.traits, &mut body, &mut members, &mut protocols); let owner = Ty::Handle { @@ -836,13 +980,28 @@ impl<'a> Gen<'a> { self.methods(&object.methods, &owner, &ty, &mut body, &mut members); self.distinct(&rust, &members); let text = format!("{rust}\n\n{}", object.docs); + let docs = docs(text.trim(), ""); + let body = body.join("\n"); + if !unique { + let _ = write!( + self.types, + "{docs}public final class {name}: {} {{\n{body}}}\n\n", + protocols.join(", ") + ); + return; + } let _ = write!( self.types, - "{}public final class {name}: {} {{\n{}}}\n\n", - docs(text.trim(), ""), - protocols.join(", "), - body.join("\n") + "{docs}public struct {name}: ~Copyable, @unchecked Sendable {{\n{body}}}\n\n" ); + // Swift 6.4 takes a noncopyable type's `Equatable`, but not yet its + // `CustomStringConvertible` or `CustomDebugStringConvertible`, whose members it keeps. + if protocols.contains(&"Equatable") { + let _ = write!( + self.types, + "#if compiler(>=6.4)\nextension {name}: Equatable {{}}\n#endif\n\n" + ); + } } fn callback(&mut self, i: usize) { @@ -917,10 +1076,13 @@ impl<'a> Gen<'a> { Type::Box(inner) => self.close(inner, e), Type::Tuple(elements) if elements.len() == 1 => self.close(&elements[0], e), Type::Tuple(elements) => { - let closes: Vec = (elements.iter().enumerate()) - .filter(|(_, ty)| moved(self.abi, ty)) - .map(|(k, ty)| self.close(ty, &format!("{e}.{k}"))) - .collect(); + let mut closes = Vec::new(); + for (k, ty) in elements.iter().enumerate() { + if self.moved(ty) { + let element = self.element(elements, e, k); + closes.push(self.close(ty, &element)); + } + } closes.join("; ") } Type::List(_, inner) => { @@ -938,7 +1100,7 @@ impl<'a> Gen<'a> { let abi = self.abi; let mut arg = Arg::default(); let mut arena = false; - if moved(abi, ty) { + if self.moved(ty) { arg.after.push(self.close(ty, e)); } match (ty, c) { @@ -960,6 +1122,11 @@ impl<'a> Gen<'a> { arg.open = Some((format!("{tr}Native.view({e}) {{ {b} in"), "}".to_string())); arg.exprs.push(b); } + // A struct the call borrows or consumes, so it lives until the call returns. + (ty, Ty::Handle { .. }) if self.unique(ty) => arg.exprs.push(match ty.base() { + Type::Option(_) => format!("Native.option({e}, {{ $0.handle }}, nil)"), + _ => format!("{e}.handle"), + }), (ty, Ty::Handle { .. }) => { let h = format!("h{k}_"); let open = match ty.base() { @@ -978,7 +1145,9 @@ impl<'a> Gen<'a> { } /// The C function C calls for a closure of type `ty`, lent for a call, taking `lent` - /// and returning `ret`: it lends each object as its class, ended once it returns. + /// and returning `ret`: it lends each object as its type, ended once it returns. A + /// struct lent `&mut` is `inout`, which Swift may assign another value; Rust's stays + /// its own, so that traps. fn trampoline(&mut self, ty: &Type, lent: &[Type], ret: &Type) -> String { let ps: Vec = (0..lent.len()).map(|k| format!("p{k}_")).collect(); let mut body = vec![format!( @@ -986,8 +1155,27 @@ impl<'a> Gen<'a> { self.sw(ty) )]; let mut args = Vec::new(); + let mut ends = Vec::new(); for (k, (ty, p)) in lent.iter().zip(&ps).enumerate() { match ty { + Type::Ref(m, object) if self.unique(object) => { + let l = format!("l{k}_"); + let name = self.sw(object); + match m { + Mutability::Not => { + body.push(format!("let {l} = {name}(raw: {p})")); + args.push(l.clone()); + } + Mutability::Mut => { + body.push(format!("var {l} = {name}(raw: {p})")); + args.push(format!("&{l}")); + ends.push(format!( + "if {l}.handle != {p} {{ fatalError(\"{name}: a lent handle was replaced\") }}" + )); + } + } + ends.push(format!("{l}._end()")); + } Type::Ref(_, object) if matches!(**object, Type::Object(_)) => { let l = format!("l{k}_"); body.push(format!("let {l} = {}(lent: {p})", self.sw(object))); @@ -998,10 +1186,18 @@ impl<'a> Gen<'a> { } } let call = format!("f_({})", args.join(", ")); - body.push(match ret { - Type::Unit => call, - ret => format!("return {}", self.lower(ret, &call)), - }); + match (ret, ends.is_empty()) { + (Type::Unit, _) => { + body.push(call); + body.extend(ends); + } + (ret, true) => body.push(format!("return {}", self.lower(ret, &call))), + (ret, false) => { + body.push(format!("let r_ = {call}")); + body.extend(ends); + body.push(format!("return {}", self.lower(ret, "r_"))); + } + } let mut params = vec!["data_".to_string()]; params.extend(ps); format!("{{ {} in\n\t{}\n}}", params.join(", "), body.join("\n\t")) @@ -1037,6 +1233,14 @@ impl<'a> Gen<'a> { let receiver = function.receiver.filter(|_| owner.is_some()); let object = matches!(owner, Some(Ty::Handle { .. })) && receiver.is_some(); + // A struct's method takes `self` as Rust does: by value, it consumes it, and Swift + // then frees the handle Rust moved the value out of. + let unique = owner_ty.is_some_and(|ty| self.unique(ty)); + let mutation = match receiver { + Some(Receiver::Owned) if unique => "consuming ", + Some(Receiver::Mut) if unique => "mutating ", + _ => "", + }; // Only a future or a `Result` throws, and a future only once it is awaited. let throws = function.future || function.err.is_some(); let tr = match function.err.is_some() && !function.future { @@ -1055,6 +1259,7 @@ impl<'a> Gen<'a> { let ty = owner_ty.expect("a method has its type").clone(); let mut arg = Arg::default(); match c { + Ty::Handle { .. } if unique => arg.exprs.push("self.handle".to_string()), Ty::Handle { .. } => { arg.open = Some((format!("{tr}self.handle.with {{ h_ in"), "}".to_string())); arg.exprs.push("h_".to_string()); @@ -1078,7 +1283,7 @@ impl<'a> Gen<'a> { if let Type::Closure(..) = param.ty { c_params.next().expect("a closure has its data"); } - params.push(format!("_ {name}: {}", self.sw(¶m.ty))); + params.push(format!("_ {name}: {}{}", self.ownership(¶m.ty), self.sw(¶m.ty))); let (arg, needs) = self.arg(¶m.ty, c, name, k, tr); arena |= needs; args.push(arg); @@ -1102,6 +1307,13 @@ impl<'a> Gen<'a> { ok => strip_refs(ok), }; let err = function.err.as_ref().map(strip_refs); + if let Some(err) = err.as_ref().filter(|err| self.unique(err)) { + self.errors.push(format!( + "fn `{}` returns the error `{}`, which holds an object Swift cannot copy, as `Swift.Error` requires", + function.path.join("::"), + self.sw(err) + )); + } let thrown = err.as_ref().map(|e| self.thrown(e)); let mut inner = Vec::new(); @@ -1230,14 +1442,17 @@ impl<'a> Gen<'a> { }; let body = body.join("\n"); let head = match (function.access, receiver) { - (Access::Field { set: false, .. }, Some(_)) if function.params.is_empty() && !throws => { + (Access::Field { set: false, .. }, Some(_)) if function.params.is_empty() && !throws && mutation.is_empty() => { format!("public var {member}{}", ret.replacen(" -> ", ": ", 1)) } (Access::Const, None) if function.params.is_empty() && !throws => { member = member.trim_matches('`').to_uppercase(); format!("public {stat}var {member}{}", ret.replacen(" -> ", ": ", 1)) } - _ => format!("public {stat}func {member}({}){effects}{ret}", params.join(", ")), + _ => format!( + "public {stat}{mutation}func {member}({}){effects}{ret}", + params.join(", ") + ), }; let code = format!("{}{head} {{\n{}\n}}\n", docs(text.trim(), ""), indent(&body)); let labels = match head.contains(" var ") { @@ -1263,7 +1478,10 @@ impl<'a> Gen<'a> { let lifted: Vec = (elements.iter().enumerate()) .map(|(n, e)| self.lift(&strip_refs(e), &format!("c_out{n}_"))) .collect(); - format!("({})", lifted.join(", ")) + match self.unique(ok) { + true => format!("{}({})", self.sw(ok), lifted.join(", ")), + false => format!("({})", lifted.join(", ")), + } } _ => self.lift(ok, "c_out_"), } @@ -1309,15 +1527,22 @@ fn thrown_value(thrown: &Thrown, lifted: &str) -> String { } } -/// Whether passing `ty` moves an object's value out: by value, boxed, or in an `Option`, -/// a list, or a tuple, unless `Copy`, and never through a reference. -fn moved(abi: &Abi<'_>, ty: &Type) -> bool { - match ty { - Type::Object(i) => !abi.api.objects[*i].copy, - Type::Option(inner) | Type::Box(inner) | Type::List(_, inner) => moved(abi, inner), - Type::Tuple(elements) => elements.iter().any(|ty| moved(abi, ty)), - _ => false, - } +/// The objects Swift holds as noncopyable structs: each but those `Copy`, and those thrown +/// as errors, which `Swift.Error` requires be copyable. +fn structs(abi: &Abi<'_>) -> BTreeSet { + let api = abi.api; + let methods = (api.records.iter().flat_map(|r| &r.methods)) + .chain(api.enums.iter().flat_map(|e| &e.methods)) + .chain(api.objects.iter().flat_map(|o| &o.methods)); + let thrown: BTreeSet = (api.functions.iter().chain(methods)) + .filter_map(|f| match f.err.as_ref().map(Type::base) { + Some(Type::Object(i)) => Some(*i), + _ => None, + }) + .collect(); + (0..api.objects.len()) + .filter(|i| !api.objects[*i].copy && !thrown.contains(i)) + .collect() } /// `ty` returned: a borrow is returned as a clone, so owned. diff --git a/ffi/tests/api.swift b/ffi/tests/api.swift index 4e1e27650..240e0e7d7 100644 --- a/ffi/tests/api.swift +++ b/ffi/tests/api.swift @@ -5,6 +5,7 @@ // breaks a contract, which must fail. import Api +import BasicContainers import Foundation // The Log callback: keeps even levels. @@ -36,6 +37,15 @@ func text(_ data: Data) -> String { String(decoding: data, as: UTF8.self) } +/// Each of `items` as `each` prints it, joined by commas. +func listed(_ items: borrowing UniqueArray, _ each: (borrowing T) -> String) -> String { + var out: [String] = [] + for i in 0.. String { var out = "" @@ -149,7 +159,7 @@ func script() { let d = Point.default() print("default \(d.x) \(d.y)") - let c = Counter.new("c", 5) + var c = Counter.new("c", 5) print("get \(c.get())") print("name \(c.name())") print("add ok \(try! c.add(10))") @@ -170,16 +180,17 @@ func script() { c.rename("d") print("name \(c.name())") print("is d \(c.`is`("d"))") - let copy = c.clone() + var copy = c.clone() _ = try! copy.add(1) print("clone \(copy.get()) original \(c.get())") - print("debug \(debug(c))") + print("debug \(c.debugDescription)") let label = c.intoLabel(p) print("label \(debug(label))") print("note \(hasText(Note(text: ""))) \(hasText(Note(text: nil)))") - let (below, above) = Counter.new("s", 7).split(5) - print("split \(below.name()) \(below.get()) \(above.name()) \(above.get())") + // A tuple holding a handle is a struct of its elements. + let split = Counter.new("s", 7).split(5) + print("split \(split._0.name()) \(split._0.get()) \(split._1.name()) \(split._1.get())") let one = only(Counter.new("o", 3)) print("only \(one.name()) \(one.get())") let (w8, w16, w32, w64) = widths(98305) @@ -208,10 +219,14 @@ func script() { } let statRows = rows([Point(x: 1, y: 2), Point(x: 3, y: 3)]) print("rows \(statRows.map { "\($0.0) \(debug($0.1)) \($0.2.x) \($0.2.y)" }.joined(separator: ", "))") - // Each handle in a tuple moves in, as one taken by value does. - let board = Board.new([("low", Counter.new("l", 1)), ("high", Counter.new("h", 9))]) + // Each handle in a tuple moves in, as one taken by value does, and an array of them is a + // `UniqueArray`. + var ranks = UniqueArray() + ranks.append(TupleStringCounter("low", Counter.new("l", 1))) + ranks.append(TupleStringCounter("high", Counter.new("h", 9))) + let board = Board.new(ranks) let ranked = board.ranked() - print("ranked \(ranked.map { "\($0.0) \($0.1.name()) \($0.1.get())" }.joined(separator: ", "))") + print("ranked \(listed(ranked) { "\($0._0) \($0._1.name()) \($0._1.get())" })") // A map crosses as its pairs and a set as its items, which Rust collects in order, each // key once, the last kept. let atlas = Atlas(points: [("b", Point(x: 1, y: 1)), ("ab", Point(x: 2, y: 3)), ("b", Point(x: 4, y: 4))], ids: [3, 1, 3]) @@ -245,13 +260,13 @@ func script() { print("title Some((\"\(title(true)!)\",)) \(title(false) == nil ? "None" : "Some") named Some((\"\(named("x")!)\",))") // Each lent counter is a new handle to a clone. - let registry = Registry.new(Counter.new("first", 1)) + var registry = Registry.new(Counter.new("first", 1)) print("last \(registry.last() != nil)") registry.push(Counter.new("last", 2)) - let first = registry.firstGet + var first = registry.firstGet _ = try! first.add(10) print("first \(registry.firstGet.get()) lent \(first.get())") - let last = registry.last()! + var last = registry.last()! _ = try! last.add(10) print("last \(registry.last()!.get()) lent \(last.get())") // A closure lent for the call edits the first counter through a handle. @@ -270,8 +285,8 @@ func script() { print("shift \(shift({ 7 }, 4))") let name = Name.owned("a") - let joined = name.join(Name.owned("b")) - print("joined \(joined.asStr()) \(debug(joined)) \(joined == name)") + var joined = name.join(Name.owned("b")) + print("joined \(joined.asStr()) \(joined.debugDescription) \(joined == name)") joined.rename("c") print("renamed \(joined.asStr())") let long = longer(joined, Name.owned("dd")) @@ -331,7 +346,7 @@ func script() { print("secs \(stamp.secs())") print("later \(Stamp.fromMillis(3000).later(stamp))") print("secs \(stamp.secs())") - let message = Message.new(stamp, Data("body".utf8)) + var message = Message.new(stamp, Data("body".utf8)) print("payload \(text(message.payloadGet))") print("stamp \(debug(message.stampGet!))") print("sent \(message.sent())") @@ -394,7 +409,7 @@ func script() { channel.send(Data("rung".utf8)) print("woken \(wakes.value)") - let list = Node.new(1, nil) + var list = Node.new(1, nil) list.push(2) print("node \(list.sum())") @@ -420,7 +435,7 @@ func script() { let beside = PairU32(left: 258, right: 9).besideSize(sized.left) print("beside \(beside.left) \(debug(beside))") // At its default, named as the type itself. - let stack = Stack.new() + var stack = Stack.new() for (i, n) in ["a", "b"].enumerated() { stack.push(Counter.new(n, UInt64(i) + 1)) } @@ -468,7 +483,7 @@ func script() { print("dropped \(dropped())") // A field and the method of its name, each its own member. - let retry = Retry.new(5) + var retry = Retry.new(5) let limit = retry.limitGet print("retry \(limit != nil ? 1 : 0) \(limit ?? 0) \(retry.limit()) \(retry.delay())") // The renamed field's setter, named as its getter. @@ -478,9 +493,9 @@ func script() { // Foreign types: a URL parsed from text, socket addresses, OS paths, and `Vec`s. let base = try! Url.parse("https://example.com/a/b") - print("url \(base) host \(host(base).map { "Some(\"\($0)\")" } ?? "None")") + print("url \(base.description) host \(host(base).map { "Some(\"\($0)\")" } ?? "None")") let url = try! resolve(base.clone(), "c?d") - print("resolve \(url) \(url == base)") + print("resolve \(url.description) \(url == base)") do { _ = try Url.parse("no scheme") } catch let e as StringError { @@ -502,10 +517,14 @@ func script() { print("sentence \(sentence(words)) \(sentence(nil))") print("sentence \(sentence([]))|") print("chunks \(chunks(Data("abcde".utf8), 2).map { Array($0) })") - let counters = counters(["x", "y"]) - print("counters \(counters.map { "\($0.name()) \($0.get())" }.joined(separator: ", "))") - // Each counter moves in, so Swift closes it. - print("worth \(worth([counters[0]], [counters[1]])) \(worth(nil, []))") + var counters = counters(["x", "y"]) + print("counters \(listed(counters) { "\($0.name()) \($0.get())" })") + // Each counter moves in. + var some = UniqueArray() + some.append(counters.remove(at: 0)) + var boxed = UniqueArray() + boxed.append(counters.remove(at: 0)) + print("worth \(worth(some, boxed)) \(worth(nil, UniqueArray()))") print("held \([heldCount(Counter.new("h", 4)), heldCount(nil)].map { $0.map { "Some(\($0))" } ?? "None" }.joined(separator: " "))") print("boxed \(boxedName(boxedCounter("b")).map { "Some(\"\($0)\")" } ?? "None") \(boxedName(nil) ?? "None") \(boxedCounter(nil) == nil)") print("less \(less(5, 2))") @@ -528,8 +547,11 @@ func script() { let tagged = tagged(doc, "t") print("tagged \(tagged.toText())") // A JSON object's entries cross as pairs of a key and its document, each moved in. - let jsonEntries = entries([("b", try! Json.parse("[1]")), ("a", try! Json.parse("2"))], "t") - print("entries \(jsonEntries.map { "\($0.0) \($0.1.toText())" }.joined(separator: ", "))") + var pairs = UniqueArray() + pairs.append(TupleStringJson("b", try! Json.parse("[1]"))) + pairs.append(TupleStringJson("a", try! Json.parse("2"))) + let jsonEntries = entries(pairs, "t") + print("entries \(listed(jsonEntries) { "\($0._0) \($0._1.toText())" })") do { _ = try Json.parse("{\"a\":") } catch let e as StringError { @@ -538,17 +560,17 @@ func script() { check(false, "parse throws its message") } let site = Site.new(base) - print("site \(site.homeGet) \(site.homeGet)") + print("site \(site.homeGet.description) \(site.homeGet.description)") // Slices, passed in and returned as clones, and the socket address of each family. print("shapes \(Shape.ALL) greetings \(GREETINGS)") - let trail = Trail.new([Point(x: 1, y: 2), Point(x: 3, y: 4)], ["a", "b"]) + var trail = Trail.new([Point(x: 1, y: 2), Point(x: 3, y: 4)], ["a", "b"]) print("trail \(trail.points()) \(trail.labels())") // Returned iterators, each collected at the call: drained labels are gone after. print("iterated \(trail.iter()) \(trail.names()) \(Array(trail.bytes()))") print("drained \(trail.drain()) then \(trail.labels())") let numbered = numbered(["p", "q"]) - print("numbered \(numbered.map { "\($0.name()) \($0.get())" }.joined(separator: ", "))") + print("numbered \(listed(numbered) { "\($0.name()) \($0.get())" })") let (next4, next6) = nexts(SocketAddr(ip: [127, 0, 0, 1], port: 8080), v6) print("nexts \(next4.ip.map(String.init).joined(separator: ".")) \(next4.port) \(next6.port) \(next6.scopeId)") } @@ -656,12 +678,14 @@ func futures() { // An object whose last reference goes while a call awaits on it stays alive until the // call ends. do { - let other = Cell(Channel.new()) - let sender = other.value!.clone() let got = Cell(nil) - q.launch { got.value = text(try await other.value!.recv()) } - q.drain() - other.value = nil + let sender: Channel + do { + let other = Channel.new() + sender = other.clone() + q.launch { got.value = text(try await other.recv()) } + q.drain() + } sender.send(Data("held".utf8)) q.drain() check(got.value == "held", "the call awaiting on an object holds it") @@ -698,34 +722,52 @@ func owned() { print("owned ok") } -/// Holds a lent counter past the call it was lent for. +// Each breaks a contract Swift's compiler checks, so it fails to compile, built alone with +// its flag. +#if MOVED +func moved() { + let c = Counter.new("c", 0) + _ = c.intoLabel(nil) + _ = c.get() +} +#endif + +#if BOXED_MOVED +func boxedMoved() { + let tail = Node.new(1, nil) + _ = Node.new(2, tail) + _ = tail.sum() +} +#endif + +#if LENT_MOVED +func lentMoved() { + var registry = Registry.new(Counter.new("c", 0)) + _ = registry.edit { counter in + _ = counter.intoLabel(nil) + return false + } +} +#endif + +#if LENT_KEPT +/// Holds a lent tally past the call it was lent for. final class Kept: @unchecked Sendable { - var counter: Counter? + var tally: Tally? } +func lentKept() { + let kept = Kept() + _ = peek(Tally.new()) { lent in + kept.tally = lent + return 0 + } +} +#endif + // Each breaks a contract, which must fail with a message naming it. func broken(_ which: String) { switch which { - case "moved": - let c = Counter.new("c", 0) - _ = c.intoLabel(nil) - _ = c.get() - case "boxed moved": - let tail = Node.new(1, nil) - _ = Node.new(2, tail) - _ = tail.sum() - case "lent moved": - _ = Registry.new(Counter.new("c", 0)).edit { counter in - _ = counter.intoLabel(nil) - return false - } - case "lent kept": - let kept = Kept() - _ = Registry.new(Counter.new("c", 0)).edit { counter in - kept.counter = counter - return false - } - _ = kept.counter!.get() // An IPv6 address where Rust takes `SocketAddrV4`. case "family": let any = SocketAddr(ip: Array(repeating: 0, count: 16), port: 1) diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 3123166b8..53ab1b3c5 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -623,7 +623,8 @@ fn kotlin_contract(kotlin: &Path) -> u64 { /// The generated Swift, built as the module `Api` with warnings as errors in Swift 6's /// language mode and type-checked in Swift 5's, which moq's package builds in, runs /// `tests/api.swift`'s script, its futures, and what releasing frees, then fails loud on -/// each broken contract and on a library of another contract. +/// each broken contract and on a library of another contract. Each contract Swift's +/// compiler checks, a move or a borrow, fails to compile instead. #[test] #[cfg_attr( all(target_os = "linux", target_arch = "aarch64"), @@ -636,6 +637,7 @@ fn api_swift() { std::fs::create_dir_all(&dir).unwrap(); let module = api.out.join("swift"); let source = module.join("api.swift"); + let collections = swift_collections(); run(swiftc(&module, 13) .args(["-swift-version", "5", "-typecheck"]) .arg(&source)); @@ -648,11 +650,13 @@ fn api_swift() { .arg(&source)); // The test's executor preference is macOS 15's. let exe = dir.join("api-test"); + let test = manifest_dir().join("tests").join("api.swift"); run(swiftc(&module, 15) .args(["-parse-as-library", "-I"]) .arg(&dir) - .arg(manifest_dir().join("tests").join("api.swift")) + .arg(&test) .arg(dir.join("libApi.a")) + .args(&collections) .arg(api.staticlib()) .args(&api.native) .arg("-o") @@ -665,14 +669,28 @@ fn api_swift() { ); run(Command::new(&exe).arg("futures")); run(Command::new(&exe).arg("owned")); + // Each in a build of its own, since the first error stops the rest; move checking runs + // on SIL, which type-checking alone skips. + for (case, message) in [ + ("MOVED", "'c' used after consume"), + ("BOXED_MOVED", "'tail' used after consume"), + ("LENT_MOVED", "missing reinitialization of inout parameter 'counter' after consume"), + ("LENT_KEPT", "implicit conversion to 'Tally?' is consuming"), + ] { + let output = swiftc(&module, 15) + .args(["-parse-as-library", "-emit-sil", "-o"]) + .arg(dir.join("broken.sil")) + .args(["-I"]) + .arg(&dir) + .arg(format!("-D{case}")) + .arg(&test) + .output() + .unwrap(); + assert!(!output.status.success(), "Swift {case} compiled"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(message), "Swift {case} printed {stderr}"); + } for (case, message) in [ - ("moved", "Counter is closed"), - ("boxed moved", "Node is closed"), - ("lent kept", "Counter is closed"), - ( - "lent moved", - "api_Counter_into_label: a lent handle's value cannot move out", - ), ("family", "api_nexts: an IPv6 socket address where IPv4 is taken"), ("stray", "a path from Rust is not UTF-8"), ("lent stray", "a path from Rust is not UTF-8"), @@ -702,6 +720,7 @@ fn api_swift() { .args(["-module-name", "Api"]) .arg(contract.join("api.swift")) .arg(contract.join("main.swift")) + .args(&collections) .arg(api.staticlib()) .args(&api.native) .arg("-o") @@ -861,8 +880,27 @@ fn url(dir: &Path) -> String { } } -/// swiftc, with warnings as errors, finding the C header's module in `module`, at this -/// macOS deployment target on macOS: 13 is the oldest the generated `Swift.Duration` allows. +/// The static libraries of swift-collections' modules the generated Swift imports, which +/// `just ffi swift-collections` builds beside their modules, where [`swiftc`] finds them. +fn swift_collections() -> Vec { + run(Command::new("just") + .arg("--justfile") + .arg(manifest_dir().join("justfile")) + .arg("swift-collections")); + let build = collections_dir(); + ["BasicContainers", "InternalCollectionsUtilities", "SpanPreview"] + .iter() + .map(|module| build.join(format!("lib{module}.a"))) + .collect() +} + +fn collections_dir() -> PathBuf { + manifest_dir().join("target").join("swift-collections").join("build") +} + +/// swiftc, with warnings as errors, finding the C header's module in `module` and +/// swift-collections' in [`collections_dir`], at this macOS deployment target on macOS: 13 +/// is the oldest the generated `Swift.Duration` allows. fn swiftc(module: &Path, macos: u8) -> Command { let mut cmd = Command::new("swiftc"); if cfg!(target_os = "macos") { @@ -872,7 +910,7 @@ fn swiftc(module: &Path, macos: u8) -> Command { }; cmd.arg("-target").arg(format!("{arch}-apple-macos{macos}")); } - cmd.args(["-warnings-as-errors", "-I"]).arg(module); + cmd.args(["-warnings-as-errors", "-I"]).arg(module).arg("-I").arg(collections_dir()); cmd } From da97005117119a26cfc8207264ce622b3c516eee Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 12:29:19 -0800 Subject: [PATCH 03/12] style: fmt --- ffi/src/swift.rs | 12 +++++++++--- ffi/tests/fixtures.rs | 10 ++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/ffi/src/swift.rs b/ffi/src/swift.rs index 65435a36d..96cb54702 100644 --- a/ffi/src/swift.rs +++ b/ffi/src/swift.rs @@ -407,7 +407,10 @@ impl<'a> Gen<'a> { Type::List(_, elem) => (self.lower(inner, &v), format!("{}()", abi.list(elem))), _ => { let c = abi.option(inner); - (format!("{c}(some: true, value: {})", self.lower(inner, &v)), format!("{c}()")) + ( + format!("{c}(some: true, value: {})", self.lower(inner, &v)), + format!("{c}()"), + ) } }; format!("Native.option({e}, {{ {v} in {some} }}, {none})") @@ -546,7 +549,8 @@ impl<'a> Gen<'a> { for ty in types { crate::ir::each(&strip_refs(ty), &mut |ty| { if let Type::Tuple(elements) = ty - && elements.len() > 1 && self.unique(ty) + && elements.len() > 1 + && self.unique(ty) { tuples.insert(abi.tuple(elements), elements.clone()); } @@ -1442,7 +1446,9 @@ impl<'a> Gen<'a> { }; let body = body.join("\n"); let head = match (function.access, receiver) { - (Access::Field { set: false, .. }, Some(_)) if function.params.is_empty() && !throws && mutation.is_empty() => { + (Access::Field { set: false, .. }, Some(_)) + if function.params.is_empty() && !throws && mutation.is_empty() => + { format!("public var {member}{}", ret.replacen(" -> ", ": ", 1)) } (Access::Const, None) if function.params.is_empty() && !throws => { diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 53ab1b3c5..0da4c94fc 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -674,7 +674,10 @@ fn api_swift() { for (case, message) in [ ("MOVED", "'c' used after consume"), ("BOXED_MOVED", "'tail' used after consume"), - ("LENT_MOVED", "missing reinitialization of inout parameter 'counter' after consume"), + ( + "LENT_MOVED", + "missing reinitialization of inout parameter 'counter' after consume", + ), ("LENT_KEPT", "implicit conversion to 'Tally?' is consuming"), ] { let output = swiftc(&module, 15) @@ -910,7 +913,10 @@ fn swiftc(module: &Path, macos: u8) -> Command { }; cmd.arg("-target").arg(format!("{arch}-apple-macos{macos}")); } - cmd.args(["-warnings-as-errors", "-I"]).arg(module).arg("-I").arg(collections_dir()); + cmd.args(["-warnings-as-errors", "-I"]) + .arg(module) + .arg("-I") + .arg(collections_dir()); cmd } From 5ba4b2626adb93b94457b517f4c022fb3f3c6550 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 12:46:26 -0800 Subject: [PATCH 04/12] feat(ffi): freeze Swift's tuple structs, and adapt moq's Swift to noncopyable handles Co-Authored-By: Claude Opus 5.5 --- ffi/moq/bench/export.swift | 33 +++++--- ffi/moq/moq.swift | 163 ++++++++++++++++++++++++++----------- ffi/src/swift.rs | 2 +- 3 files changed, 137 insertions(+), 61 deletions(-) diff --git a/ffi/moq/bench/export.swift b/ffi/moq/bench/export.swift index 8f19c386b..673ae812a 100644 --- a/ffi/moq/bench/export.swift +++ b/ffi/moq/bench/export.swift @@ -12,13 +12,13 @@ func perFrame(_ start: UInt64) -> Double { Double(DispatchTime.now().uptimeNanoseconds - start) / Double(groups * frames) } -/// A value a task sets and this thread reads once the task signals. -final class Cell: @unchecked Sendable { +/// A value a task sets and this thread takes once the task signals. +final class Cell: @unchecked Sendable { var value: T? } /// `body`'s result, waiting on this thread for it to finish on the global executor. -func block(_ body: @escaping @Sendable () async throws -> T) -> T { +func block(_ body: @escaping @Sendable () async throws -> T) -> T { let done = DispatchSemaphore(value: 0) let result = Cell() Task { @@ -26,7 +26,13 @@ func block(_ body: @escaping @Sendable () async throws -> T) -> T { done.signal() } done.wait() - return result.value! + return result.value.take()! +} + +/// The reads' time per frame, and the last frame read, which a tuple cannot hold. +struct Reads: ~Copyable { + var perFrame: Double + var last: FrameFrame } func round() throws -> String { @@ -40,7 +46,7 @@ func round() throws -> String { // Each frame stamped through a fresh timestamp handle, as a caller holding a u64 must. var start = DispatchTime.now().uptimeNanoseconds for _ in 0.. String { let at = try Timestamp.fromMicros(micros) start = DispatchTime.now().uptimeNanoseconds for _ in 0.. String { // Reads: each frame written, then read through an async func whose future is ready at // its first poll, so it never suspends. - let (read, last) = block { - let subscriber = try await track.consume().subscribe(nil) + let reads = block { + var subscriber = try await track.consume().subscribe(nil) let start = DispatchTime.now().uptimeNanoseconds var last: FrameFrame? for _ in 0..: @unchecked Sendable { } } +/// A handle a task calls `mutating` methods on: an escaping closure cannot capture a +/// noncopyable `var`, but may a class holding one. +final class Held: @unchecked Sendable { + var value: T + + init(_ value: consuming T) { + self.value = value + } +} + +/// A handle a task takes and the script reads, under a lock: a struct Swift cannot copy, so +/// it moves in and out. +final class Slot: @unchecked Sendable { + private let lock = NSLock() + private var stored: T? = nil + + func put(_ value: consuming T) { + lock.lock() + defer { lock.unlock() } + stored = consume value + } + + func take() -> T? { + lock.lock() + defer { lock.unlock() } + return stored.take() + } + + var isEmpty: Bool { + lock.lock() + defer { lock.unlock() } + return stored == nil + } +} + /// The jobs tasks resume with, run when the script drains them. final class Queue: TaskExecutor, @unchecked Sendable { private let lock = NSLock() @@ -79,31 +115,46 @@ final class Queue: TaskExecutor, @unchecked Sendable { } /// `body`'s result, waiting on this thread for it to finish on the global executor. -func block(_ body: @escaping @Sendable () async throws -> T) throws -> T { +func block(_ body: @escaping @Sendable () async throws -> T) throws -> T { let done = DispatchSemaphore(value: 0) - let result = Cell?>(nil) + let result = Slot() + let failure = Cell(nil) Task { do { - result.value = .success(try await body()) + result.put(try await body()) } catch { - result.value = .failure(error) + failure.value = error } done.signal() } done.wait() - return try result.value!.get() + if let error = failure.value { + throw error + } + return result.take()! } -/// An origin, scoped, published, and polled once by `waiter`, returning its driver and the -/// wakes counted before the origin, along with every object of it, is released on return. -func origin(_ waiter: Waiter, _ wakes: Cell) throws -> (OriginDriver, Int) { +/// Whether `path` is `other`: `==` takes a `Path`, which an optional one holds. +func same(_ path: borrowing Path?, _ other: borrowing Path) -> Bool { + switch path { + case .some(let path): return path == other + case .none: return false + } +} + +/// An origin, scoped, published, and polled once by `waiter`, returning its driver, with +/// `before` the wakes counted before the origin, along with every object of it, is +/// released on return. +func origin(_ waiter: borrowing Waiter, _ wakes: Cell, _ before: inout Int) throws -> OriginDriver { // An origin and the driver that runs it, a pair. Its root, which it lends, is returned // as a new handle to a clone. - let (origin, driver) = OriginProducer.new(OriginConfig.default()) + let pair = OriginProducer.new(OriginConfig.default()) + let origin = pair._0 + var driver = pair._1 expect(origin.root().asStr() == "", "an origin's root is empty") // Scoped beneath a root to patterns, which moq-pattern's own export builds. - let patterns = Patterns.new() + var patterns = Patterns.new() expect(try patterns.insert(Pattern.subtree("alice")), "a pattern inserts") let scoped = try origin.scope("room", patterns) expect(scoped.root().asStr() == "room", "a scope has its root") @@ -131,7 +182,7 @@ func origin(_ waiter: Waiter, _ wakes: Cell) throws -> (OriginDriver, Int) expect(cam.asStr() == "cam", "a path builds from its segments") let joined = try path.join(cam) expect(joined.asStr() == "room/alice/cam", "paths join") - expect(joined.stripPrefix(path) == cam, "a path strips its prefix") + expect(same(joined.stripPrefix(path), cam), "a path strips its prefix") expect(path.stripPrefix(joined) == nil, "a path strips no longer prefix") expect(try joined.resolve(PathRelative.new("../bob")).asStr() == "room/bob", "a relative path resolves") } @@ -140,7 +191,8 @@ func origin(_ waiter: Waiter, _ wakes: Cell) throws -> (OriginDriver, Int) // A client publishes the origin through moq-net's generic `with_publisher`, at each // type that implements its bound, by reference, so Swift keeps its handle. _ = Client.new().withPublisherProducer(origin) - return withExtendedLifetime(origin) { (driver, wakes.value) } + before = wakes.value + return driver } func script() throws { @@ -158,28 +210,36 @@ func script() throws { expect("\(e)" == "duplicate", "a duplicate track's error is Rust's") } - let subscriber = try block { try await track.consume().subscribe(nil) } + let subscriber = Held(try block { try await track.consume().subscribe(nil) }) // A group's arrival wakes the subscriber's pending receive. - let group = Cell(nil) - q.launch { group.value = try await subscriber.recvGroup() } + let group = Slot() + q.launch { + if let received = try await subscriber.value.recvGroup() { + group.put(received) + } + } q.drain() - expect(group.value == nil, "a receive waits for a group") - let writer = try track.appendGroup() + expect(group.isEmpty, "a receive waits for a group") + var writer = try track.appendGroup() expect(q.size == 1, "a group's arrival wakes the receive") q.drain() - let first = group.value! - expect(first.index() == 0, "the first group is 0") + let first = Held(group.take()!) + expect(first.value.index() == 0, "the first group is 0") // So does a frame's. - let frame = Cell(nil) - q.launch { frame.value = try await first.readFrame() } + let frame = Slot() + q.launch { + if let read = try await first.value.readFrame() { + frame.put(read) + } + } q.drain() - expect(frame.value == nil, "a read waits for a frame") + expect(frame.isEmpty, "a read waits for a frame") try writer.writeFrame(nil, Data("hello".utf8)) expect(q.size == 1, "a frame's arrival wakes the read") q.drain() - let hello = frame.value! + let hello = frame.take()! expect(text(hello.payloadGet) == "hello", "a frame has its payload") expect(hello.timestampGet == nil, "an untimed frame has no timestamp") // When it arrived: kio's clock's `Instant`, from kio's own export. @@ -202,8 +262,9 @@ func script() throws { try writer.writeFrame(nil, world) try writer.finish() try block { - expect(try await first.readFrame()?.payloadGet == world, "the last frame reads") - expect(try await first.readFrame() == nil, "a finished group reads nil") + let last = try await first.value.readFrame() + expect(last?.payloadGet == world, "the last frame reads") + expect(try await first.value.readFrame() == nil, "a finished group reads nil") } // The driver, polled as a language's event loop would: at the time, and with a waiter @@ -212,7 +273,8 @@ func script() throws { // of its own ends by returning. let wakes = Cell(0) let waiter = Waiter.new { wakes.value += 1 } - let (driver, before) = try origin(waiter, wakes) + var before = 0 + var driver = try origin(waiter, wakes, &before) expect(wakes.value > before, "the last owner's release wakes the driver") do { _ = try driver.poll(clockNow(), waiter) @@ -247,9 +309,9 @@ func script() throws { // A catalog at moq-mux's untyped extension, from the config Swift builds, whose generic // `is_claimed` is named by the last segment of its argument, and whose `mutate` lends // the catalog to Swift. - let publisher = BroadcastInfo.new().produce() + var publisher = BroadcastInfo.new().produce() let extra = MuxCatalogConfig.default().withCatalogExtra(HangCatalogExtra.default()) - let catalog = try MuxCatalogProducerExtra.new(publisher, extra) + var catalog = try MuxCatalogProducerExtra.new(&publisher, extra) expect(!catalog.isClaimedBinaryConfig("state"), "an unclaimed name is not claimed") let read = Cell(0) try catalog.mutate { lent in @@ -262,7 +324,7 @@ func script() throws { // A JSON track the catalog lists, published through moq-mux's generic `json_snapshot` at // a JSON document, and a section Swift parses from text, set in the lent catalog and // read back as text. - let status = try catalog.jsonSnapshotConfig( + var status = try catalog.jsonSnapshotConfig( try publisher.createTrack("status", TrackInfo.default().withTimescale(Timescale.MILLI)), MuxJsonConfig.default(), MuxJsonOptions.default()) expect(status.name() == "status", "a JSON snapshot has its track's name") expect(catalog.isClaimedJsonConfig("status"), "the JSON snapshot claims its name") @@ -270,12 +332,14 @@ func script() throws { // back as JSON text. let on = try Json.parse(#"{"on": true}"#) try status.update(on, Timestamp.now()) + let latest = Held(status.consume()) try block { - let frame = try await status.consume().recvGroup()?.readFrame() + var group = try await latest.value.recvGroup() + let frame = try await group?.readFrame() expect(text(frame?.payloadGet) == #"{"on":true}"#, "a subscriber reads the latest value") } try catalog.mutate { lent in - let sections = lent.extGet + var sections = lent.extGet try! sections.set("app", Json.parse(#"{"v": [1, true]}"#)) lent.extSet(sections) } @@ -293,7 +357,8 @@ func script() throws { expect(e.error == "EOF while parsing a value at line 1 column 5", "a parse error is serde_json's") } do { - try MuxCatalogHangExtra.default().set("json", Json.parse("1")) + var hang = MuxCatalogHangExtra.default() + try hang.set("json", Json.parse("1")) expect(false, "a reserved section is an error") } catch let e as MuxError { expect("\(e)" == "reserved catalog section: json", "a reserved section is moq-mux's error") @@ -311,17 +376,17 @@ func script() throws { expect(e.error == "relative URL without a base", "a URL's parse error is Rust's") } // A session's config, its URL and pinned fingerprints set as Rust assigns them. - let connect = TokioConnectConfig.default() - let tls = connect.tlsGet + var connect = TokioConnectConfig.default() + var tls = connect.tlsGet tls.fingerprintSet(["ab:cd"]) connect.tlsSet(tls) connect.urlSet(try Url.parse("https://relay.example:4443/demo")) expect(connect.tlsGet.fingerprintGet == ["ab:cd"], "a TLS config keeps its fingerprints") let url = connect.urlGet! let versions = Versions.default() - let dialed = AuthTokens.new([]).dial(url, versions).urlGet + let dialed = AuthTokens.new(UniqueArray()).dial(url, versions).urlGet expect(dialed == url, "a dial keeps its URL") - expect("\(dialed)" == "https://relay.example:4443/demo", "a URL prints as Rust's") + expect(dialed.description == "https://relay.example:4443/demo", "a URL prints as Rust's") // A `Vec`, as an array. expect(!ALPNS.isEmpty, "a constant slice crosses as an array") let alpns = versions.alpns() @@ -332,7 +397,7 @@ func script() throws { expect("\(bind)" == "[::1]:4443", "an IPv6 bind prints as Rust's") let v4 = SocketAddr(ip: [127, 0, 0, 1], port: 0) expect("\(TokioListenBind.addr(v4))" == "127.0.0.1:0", "an IPv4 bind prints as Rust's") - let listen = TokioListenConfig.default() + var listen = TokioListenConfig.default() listen.bindSet(bind) // The preferred address of each family, which takes only that family. listen.preferredV4Set(v4) @@ -355,28 +420,31 @@ func script() throws { let live = try served.createBroadcast("live") try live.announce(OriginRoute.default()) let liveTrack = try live.broadcast().createTrack("video", nil) - let liveGroup = try liveTrack.appendGroup() + var liveGroup = try liveTrack.appendGroup() try liveGroup.writeFrame(nil, Data("hello".utf8)) try liveGroup.finish() - let serverConfig = TokioServerConfig.default() - let listen = serverConfig.listenGet + var serverConfig = TokioServerConfig.default() + var listen = serverConfig.listenGet listen.bindSet(.addr(SocketAddr(ip: Array(repeating: 0, count: 16), port: 0))) - let tls = listen.tlsGet + var tls = listen.tlsGet tls.generateSet(["localhost"]) listen.tlsSet(tls) serverConfig.listenSet(listen) let server = try serverConfig.init_() let local = try server.localAddr() expect(local.port != 0, "a server bound to port 0 reads back the port it got") - let serving = Task { try await server.servePublish(served.consume()) } + // Serving consumes the server, which the task takes. + let owned = Slot() + owned.put(server) + let serving = Task { try await owned.take()!.servePublish(served.consume()) } - let connect = TokioConnectConfig.default() + var connect = TokioConnectConfig.default() connect.urlSet(try Url.parse("moqt://localhost:\(local.port)")) - let insecure = connect.tlsGet + var insecure = connect.tlsGet insecure.insecureSet(true) connect.tlsSet(insecure) - let clientConfig = TokioClientConfig.default() + var clientConfig = TokioClientConfig.default() clientConfig.connectSet(connect) let client = try clientConfig.init_() let received = tokioOriginSpawn() @@ -385,8 +453,9 @@ func script() throws { expect(connection != nil, "a client with a URL dials it") let established = try await connection!.established() - let subscriber = try await remote.request("live", nil).track("video").subscribe(nil) - let frame = try await subscriber.recvGroup()?.readFrame() + var subscriber = try await remote.request("live", nil).track("video").subscribe(nil) + var group = try await subscriber.recvGroup() + let frame = try await group?.readFrame() expect(text(frame?.payloadGet) == "hello", "a session reads the frame the server serves") try await established.close() diff --git a/ffi/src/swift.rs b/ffi/src/swift.rs index 96cb54702..eae143e7c 100644 --- a/ffi/src/swift.rs +++ b/ffi/src/swift.rs @@ -621,7 +621,7 @@ impl<'a> Gen<'a> { let shown: Vec<&str> = types.iter().map(|t| t.trim_start_matches("Swift.")).collect(); let _ = write!( self.types, - "/// The tuple `({})`, as a struct, since it holds a value Swift cannot copy.\npublic struct {name}: ~Copyable, Sendable {{\n{fields}\n\tpublic init({}) {{\n{assigns}\t}}\n}}\n\n", + "/// The tuple `({})`, as a struct, since it holds a value Swift cannot copy. Frozen, so\n/// another module may move each element out.\n@frozen public struct {name}: ~Copyable, Sendable {{\n{fields}\n\tpublic init({}) {{\n{assigns}\t}}\n}}\n\n", shown.join(", "), params.join(", ") ); From fc5fb3f943d28c5fb45786c20848f3862322a7ad Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 13:46:26 -0800 Subject: [PATCH 05/12] feat(ffi)!: label Swift's optional borrowed objects, so their overloads differ in name moq's Source::follow takes two Option<&T>, whose overloads leaving out either were both follow(_:). Each present one is now labelled by its name, as a defaulted argument is: clamped(), clamped(low:), clamped(high:), clamped(low:high:). Co-Authored-By: Claude Opus 5.5 --- ffi/README.md | 7 +++++-- ffi/fixtures/api/src/lib.rs | 7 +++++++ ffi/src/swift.rs | 15 +++++++-------- ffi/tests/api.swift | 9 ++++++++- quest/m1/ffi-export/swift-noncopyable.md | 17 ++++++++++++----- 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/ffi/README.md b/ffi/README.md index 8a11d634b..7a0f25fb9 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -428,7 +428,7 @@ and, for `Swift.Duration`, macOS 13 or iOS 16, and builds in either language mod - **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`, takes a trailing `_`. Parameters have no labels, so a call reads as Rust's, and a + `init`, takes a trailing `_`. 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 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 @@ -437,7 +437,10 @@ and, for `Swift.Duration`, macOS 13 or iOS 16, and builds in either language mod `&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`. `Clone` is `clone()`, and `PartialEq` is `==`, which Swift 6.4 makes + `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 diff --git a/ffi/fixtures/api/src/lib.rs b/ffi/fixtures/api/src/lib.rs index 47fc677bd..2304e9a8d 100644 --- a/ffi/fixtures/api/src/lib.rs +++ b/ffi/fixtures/api/src/lib.rs @@ -152,6 +152,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; diff --git a/ffi/src/swift.rs b/ffi/src/swift.rs index 034e5498d..dd4e67045 100644 --- a/ffi/src/swift.rs +++ b/ffi/src/swift.rs @@ -1211,12 +1211,7 @@ impl<'a> Gen<'a> { /// `function`'s Swift forms and their member names, members of `owner` (whose IR type /// is `owner_ty`) if it has one: an overload for each choice of passing or leaving out /// each `Option<&T>` of a struct, since an optional holds a struct only by consuming it. - fn function( - &mut self, - function: &Function, - owner: Option<&Ty>, - owner_ty: Option<&Type>, - ) -> Vec<(String, String)> { + fn function(&mut self, function: &Function, owner: Option<&Ty>, owner_ty: Option<&Type>) -> Vec<(String, String)> { let optional: Vec = (function.params.iter().enumerate()) .filter(|(_, p)| self.lent_option(&p.ty)) .map(|(k, _)| k) @@ -1336,7 +1331,9 @@ impl<'a> Gen<'a> { if let Type::Option(lent) = ¶m.ty && self.lent_option(¶m.ty) { - params.push(format!("_ {name}: borrowing {}", self.sw(lent))); + // Labelled, as a defaulted argument is, so that overloads leaving out + // different ones differ in name. + params.push(format!("{name}: borrowing {}", self.sw(lent))); args.push(Arg { exprs: vec![format!("{name}.handle")], ..Arg::default() @@ -1542,7 +1539,9 @@ impl<'a> Gen<'a> { let code = format!("{}{head} {{\n{}\n}}\n", docs(text.trim(), ""), indent(&body)); let labels = match head.contains(" var ") { true => Vec::new(), - false => params.iter().map(|_| "_".to_string()).collect(), + false => (params.iter()) + .map(|p| unquote(p.split([' ', ':']).next().expect("a parameter has its label"))) + .collect(), }; (code, full(is_static, &member, labels)) } diff --git a/ffi/tests/api.swift b/ffi/tests/api.swift index dfe99620a..938084a89 100644 --- a/ffi/tests/api.swift +++ b/ffi/tests/api.swift @@ -539,7 +539,14 @@ func script() { let two = Counter.new("two", 2) var three: Counter? = Counter.new("three", 3) var none: Counter? = nil - print("plus \(two.plus(three!)) \(two.plus())") + print("plus \(two.plus(other: three!)) \(two.plus())") + // Each optional borrowed parameter is an overload with and without its label. + let one = Counter.new("one", 1) + check( + two.clamped() == 2 && two.clamped(low: three!) == 3 && two.clamped(high: one) == 1 + && two.clamped(low: one, high: three!) == 2, + "clamped overloads each optional borrow" + ) print("add_to \(two.addTo(&three)) \(two.addTo(&none)) \(three!.get())") } do { diff --git a/quest/m1/ffi-export/swift-noncopyable.md b/quest/m1/ffi-export/swift-noncopyable.md index ad2af3a9c..de6924533 100644 --- a/quest/m1/ffi-export/swift-noncopyable.md +++ b/quest/m1/ffi-export/swift-noncopyable.md @@ -29,7 +29,10 @@ has the scripts that generated the stricter forms: gain (rejected: keeping it as a backstop). The compiler then refuses everything it guarded; the one case left, a data race in Swift 5 mode, is already undefined behavior in Swift. The spike measured `get()` at 24.6 ns with it and 17.9 ns without, against a - debug library on a loaded host. This is the Swift side of + debug library on a loaded host. Against a release library, the median of 5 interleaved + runs, taken on a loaded host (load average 28.7 on 24 cores), measured `get()` at 18.2 ns + with it and 10.2 ns without, and a create, call and free at 48.7 ns and 29.6 ns, so it is + dropped. This is the Swift side of [checks-raise](/quest/m1/ffi-export/checks-raise.md)'s one accounting. - ✅ m1, in the exporter epic (rejected: m2), since the switch breaks the generated Swift API, which is cheapest before moq adopts the bindings. @@ -38,15 +41,19 @@ has the scripts that generated the stricter forms: makes a caller holding a plain handle move it into an optional and back; a generated `Borrowed` view built at each call). Wrapping a borrowed handle in an optional consumes it. moq's items have one such parameter each; refuse past a small bound at the item's span. -- Swift 6.4 conformances (`Equatable`, `Hashable`, `CustomStringConvertible`, + Each present one is labelled by its name, as a defaulted argument is, so overloads leaving + out different ones differ in name: moq's `Source::follow` takes two. The bound is two. +- ✅ Swift 6.4 conformances (`Equatable`, `Hashable`, `CustomStringConvertible`, `CustomDebugStringConvertible` for noncopyable types, from [SE-0499](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0499-support-non-copyable-simple-protocols.md)) come in under `#if compiler(>=6.4)`. Until then `debug(c)` is `c.debugDescription` and - `"\(url)"` is `url.description`; `==` stays a static operator. -- A `&mut self` call needs the handle in a `var`. + `"\(url)"` is `url.description`; `==` stays a static operator. An object exports no + hash, and Swift 6.4's snapshot conforms a noncopyable type to `Equatable` but not yet to + either `Custom*StringConvertible`, so only `Equatable` comes in. +- ✅ A `&mut self` call needs the handle in a `var`. - Check on an Apple platform that `Optional<~Copyable>` metadata back-deploys to the deployment target the generated package declares; the spike ran on Linux only. -- [moq-dev/rust2#678](https://github.com/moq-dev/rust2/pull/678)'s `&mut` in/out becomes +- ✅ [moq-dev/rust2#678](https://github.com/moq-dev/rust2/pull/678)'s `&mut` in/out becomes `inout`, exclusive at compile time, and `Option<&mut T>` becomes `inout T?`. Whichever lands second adapts to the other. From e8c853583aa57f392ae3f8d6ed5fb19582f9b01f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 13:51:07 -0800 Subject: [PATCH 06/12] fix(ffi): refuse Swift's optional borrowed objects past the bound at the item's span The driver now asks the Swift generator which functions take more optional borrowed objects than its overloads cover, and refuses each at its span before any language is generated, as AGENTS.md requires of an unsupported construct. Co-Authored-By: Claude Opus 5.5 --- ffi/fixtures/reject/expected.txt | 1 + ffi/fixtures/reject/src/lib.rs | 6 +++ ffi/src/driver.rs | 6 +++ ffi/src/swift.rs | 66 ++++++++++++++++++------ quest/m1/ffi-export/swift-noncopyable.md | 3 +- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/ffi/fixtures/reject/expected.txt b/ffi/fixtures/reject/expected.txt index 55c5cf817..922cfabcb 100644 --- a/ffi/fixtures/reject/expected.txt +++ b/ffi/fixtures/reject/expected.txt @@ -104,4 +104,5 @@ fixtures/reject/src/lib.rs:864:9: rust2-ffi: cannot export use `renamed`: re-exp fixtures/reject/src/lib.rs:875: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:895: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:911: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:918: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:94:1: rust2-ffi: cannot export fn `first`: borrowed return that is not Clone `Opaque`; exclude it in the config to leave it out diff --git a/ffi/fixtures/reject/src/lib.rs b/ffi/fixtures/reject/src/lib.rs index de34977a3..766ef7116 100644 --- a/ffi/fixtures/reject/src/lib.rs +++ b/ffi/fixtures/reject/src/lib.rs @@ -912,3 +912,9 @@ pub fn poll_loose(waiter: &Loose) -> std::task::Poll<()> { waiter.0.wake_by_ref(); 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() +} diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index 29f0f3206..26873a5f6 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -133,6 +133,12 @@ impl Callbacks for Export { .with_span_note(*first_span, format!("{first} is here")) .emit(); } + for (span, why) in crate::swift::refusals(&abi) { + tcx.dcx().span_err( + span, + format!("rust2-ffi: {why}; exclude it in the config to leave it out"), + ); + } if tcx.dcx().has_errors().is_some() { return Compilation::Stop; } diff --git a/ffi/src/swift.rs b/ffi/src/swift.rs index dd4e67045..cf349b19e 100644 --- a/ffi/src/swift.rs +++ b/ffi/src/swift.rs @@ -23,6 +23,8 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Write; +use rustc_span::Span; + use crate::abi::{self, Abi, Ty, pascal}; use crate::ir::{Access, Function, Int, Literal, Mutability, Param, Receiver, Type}; @@ -235,12 +237,7 @@ impl<'a> Gen<'a> { /// Whether Swift's value of `ty` is noncopyable: it holds an object's struct. fn unique(&self, ty: &Type) -> bool { - match ty { - Type::Object(i) => self.structs.contains(i), - Type::Option(inner) | Type::Box(inner) | Type::Ref(_, inner) | Type::List(_, inner) => self.unique(inner), - Type::Tuple(elements) => elements.iter().any(|e| self.unique(e)), - _ => false, - } + unique(&self.structs, ty) } /// The ownership a parameter of type `ty` declares, which Swift requires of a @@ -1216,14 +1213,10 @@ impl<'a> Gen<'a> { .filter(|(_, p)| self.lent_option(&p.ty)) .map(|(k, _)| k) .collect(); - if optional.len() > MAX_OPTIONAL { - self.errors.push(format!( - "fn `{}` takes {} optional borrowed objects, each an overload with and without it in Swift, past the {MAX_OPTIONAL} it exports", - function.path.join("::"), - optional.len() - )); - return Vec::new(); - } + assert!( + optional.len() <= MAX_OPTIONAL, + "the driver refuses past the bound first" + ); (0..1usize << optional.len()) .map(|mask| { let left: BTreeSet = (optional.iter().enumerate()) @@ -1237,7 +1230,7 @@ impl<'a> Gen<'a> { /// Whether `ty` is an `Option<&T>` of a struct. fn lent_option(&self, ty: &Type) -> bool { - matches!(ty, Type::Option(inner) if matches!(&**inner, Type::Ref(Mutability::Not, object) if self.unique(object))) + lent_option(&self.structs, ty) } /// One of `function`'s Swift forms, leaving out the optional borrowed parameters in @@ -1615,6 +1608,49 @@ fn thrown_value(thrown: &Thrown, lifted: &str) -> String { /// overloads. const MAX_OPTIONAL: usize = 2; +/// Each function or method taking more optional borrowed structs than [`MAX_OPTIONAL`], +/// with its span and why Swift cannot export it, which the driver refuses before any +/// language is generated. +pub fn refusals(abi: &Abi<'_>) -> Vec<(Span, String)> { + let structs = structs(abi); + let api = abi.api; + let methods = (api.records.iter().flat_map(|r| &r.methods)) + .chain(api.enums.iter().flat_map(|e| &e.methods)) + .chain(api.objects.iter().flat_map(|o| &o.methods)); + (api.functions.iter().map(|f| ("fn", f))) + .chain(methods.map(|f| ("method", f))) + .filter_map(|(kind, function)| { + let optional = (function.params.iter()) + .filter(|p| lent_option(&structs, &p.ty)) + .count(); + (optional > MAX_OPTIONAL).then(|| { + ( + function.span, + format!( + "cannot export {kind} `{}`: {optional} optional borrowed objects, past the {MAX_OPTIONAL} Swift takes, which overloads each with and without it since its optional holds an object only by consuming it", + function.shown[1..].join("::") + ), + ) + }) + }) + .collect() +} + +/// Whether Swift's value of `ty` is noncopyable: it holds one of `structs`. +fn unique(structs: &BTreeSet, ty: &Type) -> bool { + match ty { + Type::Object(i) => structs.contains(i), + Type::Option(inner) | Type::Box(inner) | Type::Ref(_, inner) | Type::List(_, inner) => unique(structs, inner), + Type::Tuple(elements) => elements.iter().any(|e| unique(structs, e)), + _ => false, + } +} + +/// Whether `ty` is an `Option<&T>` of one of `structs`. +fn lent_option(structs: &BTreeSet, ty: &Type) -> bool { + matches!(ty, Type::Option(inner) if matches!(&**inner, Type::Ref(Mutability::Not, object) if unique(structs, object))) +} + /// The objects Swift holds as noncopyable structs: each but those `Copy`, and those thrown /// as errors, which `Swift.Error` requires be copyable. fn structs(abi: &Abi<'_>) -> BTreeSet { diff --git a/quest/m1/ffi-export/swift-noncopyable.md b/quest/m1/ffi-export/swift-noncopyable.md index de6924533..15b2a538b 100644 --- a/quest/m1/ffi-export/swift-noncopyable.md +++ b/quest/m1/ffi-export/swift-noncopyable.md @@ -42,7 +42,8 @@ has the scripts that generated the stricter forms: `Borrowed` view built at each call). Wrapping a borrowed handle in an optional consumes it. moq's items have one such parameter each; refuse past a small bound at the item's span. Each present one is labelled by its name, as a defaulted argument is, so overloads leaving - out different ones differ in name: moq's `Source::follow` takes two. The bound is two. + out different ones differ in name: moq's `Source::follow` takes two. The bound is two, + refused at the item's span before any language is generated. - ✅ Swift 6.4 conformances (`Equatable`, `Hashable`, `CustomStringConvertible`, `CustomDebugStringConvertible` for noncopyable types, from [SE-0499](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0499-support-non-copyable-simple-protocols.md)) From 19c657ea5db8fb3cfc51648a1ed1ad5b1429cfa2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 14:00:32 -0800 Subject: [PATCH 07/12] fix(ffi): borrow a struct's list and option in Swift's cleanup, and refuse an error holding one at its span A list or option holding a struct beside a moved class closed the class through for-in and if-let, which consume a noncopyable value: it now loops over indices and borrows through Native.option. A function whose error holds a struct is refused at its span with the bound, before any language is generated. Co-Authored-By: Claude Opus 5.5 --- ffi/fixtures/api/src/lib.rs | 10 +++++ ffi/fixtures/reject/expected.txt | 1 + ffi/fixtures/reject/src/lib.rs | 6 +++ ffi/src/swift.rs | 64 ++++++++++++++++++-------------- ffi/tests/api.swift | 16 ++++++++ 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/ffi/fixtures/api/src/lib.rs b/ffi/fixtures/api/src/lib.rs index 2304e9a8d..798784b16 100644 --- a/ffi/fixtures/api/src/lib.rs +++ b/ffi/fixtures/api/src/lib.rs @@ -2120,6 +2120,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::>() + .join(", ") +} + /// Always refuses, for `reason`. pub fn refuse(reason: &str) -> Result> { Err(Box::new(Refusal { diff --git a/ffi/fixtures/reject/expected.txt b/ffi/fixtures/reject/expected.txt index 922cfabcb..a7d8750d5 100644 --- a/ffi/fixtures/reject/expected.txt +++ b/ffi/fixtures/reject/expected.txt @@ -105,4 +105,5 @@ fixtures/reject/src/lib.rs:875:2: rust2-ffi: cannot export method `Tally::poll_b fixtures/reject/src/lib.rs:895: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:911: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:918: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:924: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:94:1: rust2-ffi: cannot export fn `first`: borrowed return that is not Clone `Opaque`; exclude it in the config to leave it out diff --git a/ffi/fixtures/reject/src/lib.rs b/ffi/fixtures/reject/src/lib.rs index 766ef7116..327cd7240 100644 --- a/ffi/fixtures/reject/src/lib.rs +++ b/ffi/fixtures/reject/src/lib.rs @@ -918,3 +918,9 @@ pub fn poll_loose(waiter: &Loose) -> std::task::Poll<()> { 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> { + Err(vec![Opaque(value)]) +} diff --git a/ffi/src/swift.rs b/ffi/src/swift.rs index cf349b19e..d071e745e 100644 --- a/ffi/src/swift.rs +++ b/ffi/src/swift.rs @@ -1070,6 +1070,17 @@ impl<'a> Gen<'a> { /// call moved them out. fn close(&mut self, ty: &Type, e: &str) -> String { match ty { + // A struct's container is borrowed, since `if let` and `for in` would consume it. + Type::Option(inner) if self.unique(inner) => { + let v = self.fresh("v"); + let close = self.close(inner, &v); + format!("Native.option({e}, {{ {v} in {close} }}, ())") + } + Type::List(_, inner) if self.unique(inner) => { + let i = self.fresh("i"); + let close = self.close(inner, &format!("{e}[{i}]")); + format!("for {i} in {e}.indices {{ {close} }}") + } Type::Option(inner) => { let v = self.fresh("v"); let close = self.close(inner, &v); @@ -1376,13 +1387,6 @@ impl<'a> Gen<'a> { ok => strip_refs(ok), }; let err = function.err.as_ref().map(strip_refs); - if let Some(err) = err.as_ref().filter(|err| self.unique(err)) { - self.errors.push(format!( - "fn `{}` returns the error `{}`, which holds an object Swift cannot copy, as `Swift.Error` requires", - function.path.join("::"), - self.sw(err) - )); - } let thrown = err.as_ref().map(|e| self.thrown(e)); let mut inner = Vec::new(); @@ -1608,32 +1612,38 @@ fn thrown_value(thrown: &Thrown, lifted: &str) -> String { /// overloads. const MAX_OPTIONAL: usize = 2; -/// Each function or method taking more optional borrowed structs than [`MAX_OPTIONAL`], -/// with its span and why Swift cannot export it, which the driver refuses before any -/// language is generated. +/// Each function or method Swift cannot export, with its span and why, which the driver +/// refuses before any language is generated: one taking more optional borrowed structs +/// than [`MAX_OPTIONAL`], or returning an error that holds one, which `Swift.Error` +/// requires be copyable. pub fn refusals(abi: &Abi<'_>) -> Vec<(Span, String)> { let structs = structs(abi); let api = abi.api; let methods = (api.records.iter().flat_map(|r| &r.methods)) .chain(api.enums.iter().flat_map(|e| &e.methods)) .chain(api.objects.iter().flat_map(|o| &o.methods)); - (api.functions.iter().map(|f| ("fn", f))) - .chain(methods.map(|f| ("method", f))) - .filter_map(|(kind, function)| { - let optional = (function.params.iter()) - .filter(|p| lent_option(&structs, &p.ty)) - .count(); - (optional > MAX_OPTIONAL).then(|| { - ( - function.span, - format!( - "cannot export {kind} `{}`: {optional} optional borrowed objects, past the {MAX_OPTIONAL} Swift takes, which overloads each with and without it since its optional holds an object only by consuming it", - function.shown[1..].join("::") - ), - ) - }) - }) - .collect() + let mut refusals = Vec::new(); + for (kind, function) in (api.functions.iter().map(|f| ("fn", f))).chain(methods.map(|f| ("method", f))) { + let what = format!("cannot export {kind} `{}`", function.shown[1..].join("::")); + let optional = (function.params.iter()) + .filter(|p| lent_option(&structs, &p.ty)) + .count(); + if optional > MAX_OPTIONAL { + refusals.push(( + function.span, + format!( + "{what}: {optional} optional borrowed objects, past the {MAX_OPTIONAL} Swift takes, which overloads each with and without it since its optional holds an object only by consuming it" + ), + )); + } + if function.err.as_ref().is_some_and(|err| unique(&structs, err)) { + refusals.push(( + function.span, + format!("{what}: error holding an object Swift cannot copy, as `Swift.Error` requires"), + )); + } + } + refusals } /// Whether Swift's value of `ty` is noncopyable: it holds one of `structs`. diff --git a/ffi/tests/api.swift b/ffi/tests/api.swift index 938084a89..2dce548b1 100644 --- a/ffi/tests/api.swift +++ b/ffi/tests/api.swift @@ -540,6 +540,22 @@ func script() { var three: Counter? = Counter.new("three", 3) var none: Counter? = nil print("plus \(two.plus(other: three!)) \(two.plus())") + // A list and an option holding a struct beside a class, whose handle each moves. + func refusal(_ reason: String) -> Refusal { + do { + _ = try refuse(reason) + } catch let refusal as Refusal { + return refusal + } catch {} + fatalError("refuse returned") + } + var pairs = UniqueArray() + pairs.append(TupleCounterRefusal(Counter.new("a", 1), refusal("x"))) + check( + reasons(pairs, TupleCounterRefusal(Counter.new("b", 2), refusal("y"))) + == "a refused: x, b refused: y", + "reasons moves a struct and a class out of a list and an option" + ) // Each optional borrowed parameter is an overload with and without its label. let one = Counter.new("one", 1) check( From 046347d3e94793837a4d272a67bd76c8f839465f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 20:25:54 -0800 Subject: [PATCH 08/12] test(ffi): bind main's Batch as var for its &mut calls in Swift Co-Authored-By: Claude Opus 5.5 --- ffi/tests/api.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ffi/tests/api.swift b/ffi/tests/api.swift index 701a74eb6..7c90b52d6 100644 --- a/ffi/tests/api.swift +++ b/ffi/tests/api.swift @@ -304,11 +304,11 @@ func script() { } print("scaled \(scaledHex.joined(separator: " "))") check(U128(high: .max, low: .max).description == "340282366920938463463374607431768211455", "a u128's decimal digits") - let batch = Batch.new(), batch2 = pair() + var batch = Batch.new(), batch2 = pair() let accepted = (1...5).filter { batch.push($0) }.count let paired = (1...3).filter { batch2.push($0) }.count - let emptied = drain4(batch) - print("batch \(batch.capacity()) \(accepted) \(emptied) \(drain4(batch)) \(batch2.capacity()) \(paired) \(drain2(batch2))") + let emptied = drain4(&batch) + print("batch \(batch.capacity()) \(accepted) \(emptied) \(drain4(&batch)) \(batch2.capacity()) \(paired) \(drain2(&batch2))") print("capacities \(capacities42(batch, batch2)) \(capacities22(batch2, batch2))") let wide = widen(RangeU64(start: 2, end: 5), 1) print("widen \(wide.start) \(wide.end)") @@ -413,7 +413,8 @@ func script() { } catch { print("nonempty err \(error)") } - let filled = try await fill4(Batch.new(), 9) + fill2(Api.pair(), 1) + var four = Batch.new(), two = Api.pair() + let filled = try await fill4(&four, 9) + fill2(&two, 1) print("fill \(filled.map { Swift.String($0) }.joined(separator: " "))") // The send happens at the call, before the future is first polled. try await channel.sendLater(Data("eager".utf8)) From 66705d6f6a36d25a1afc39e5053ea330fa993dce Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 21:51:51 -0800 Subject: [PATCH 09/12] chore(quest): finish quest/m1/ffi-export/swift-noncopyable.md The nightly's macOS job passed on 046347d3: the generated module and swift-collections compile at the macOS 13 deployment target on Apple's toolchain, and the api fixture's Swift and moq's session run. Co-Authored-By: Claude Opus 5.5 --- quest/m1/ffi-export/swift-noncopyable.md | 67 ------------------------ 1 file changed, 67 deletions(-) delete mode 100644 quest/m1/ffi-export/swift-noncopyable.md diff --git a/quest/m1/ffi-export/swift-noncopyable.md b/quest/m1/ffi-export/swift-noncopyable.md deleted file mode 100644 index 15b2a538b..000000000 --- a/quest/m1/ffi-export/swift-noncopyable.md +++ /dev/null @@ -1,67 +0,0 @@ -# [L] Swift handles are non-copyable - -## Goal - -[rust2-ffi](/ffi/README.md)'s Swift generator emits each object handle (a type that is not -`Copy` and not an error) as a `~Copyable` struct, with `consuming`, `borrowing`, -`mutating`, and `inout` where Rust takes `self`, `&self`, `&mut self`, and `&mut`. A call on -a moved handle, or a lent handle kept past its call, is a compile error where it traps today. -Proven by `ffi/tests/api.swift`: its `moved`, `boxed moved`, `lent moved`, and `lent kept` -cases fail to compile, and the rest of the script still matches the native transcript, on -Linux and on an Apple platform. - -Errors stay classes, since `Swift.Error` requires `Copyable`. `Sendable` is -[not-send](/quest/m1/ffi-export/not-send.md)'s, and the other languages are out of scope. - -## Plan - -Decided with the maintainer on 2026-11-09, from the spike -[moq-dev/rust2#688](https://github.com/moq-dev/rust2/pull/688), whose PR body has the -report and whose [evidence comment](https://github.com/moq-dev/rust2/pull/688#issuecomment-6758972634) -has the scripts that generated the stricter forms: - -- ✅ Switch handles to `~Copyable` structs, with tuples holding a handle as generated named - structs and arrays as swift-collections' `UniqueArray` (rejected: a generated list type - of rust2's own; waiting for Swift's own `Array` to hold noncopyable values; no switch). - Swift 6.3 holds a noncopyable value in neither, which ruled out 9 of the api fixture's - exports in the spike. The generated module gains the swift-collections dependency. -- ✅ Drop the per-handle atomic hold count once a release-build measurement confirms the - gain (rejected: keeping it as a backstop). The compiler then refuses everything it - guarded; the one case left, a data race in Swift 5 mode, is already undefined behavior - in Swift. The spike measured `get()` at 24.6 ns with it and 17.9 ns without, against a - debug library on a loaded host. Against a release library, the median of 5 interleaved - runs, taken on a loaded host (load average 28.7 on 24 cores), measured `get()` at 18.2 ns - with it and 10.2 ns without, and a create, call and free at 48.7 ns and 29.6 ns, so it is - dropped. This is the Swift side of - [checks-raise](/quest/m1/ffi-export/checks-raise.md)'s one accounting. -- ✅ m1, in the exporter epic (rejected: m2), since the switch breaks the generated Swift - API, which is cheapest before moq adopts the bindings. -- ✅ An `Option<&T>` parameter gets an overload per optional borrowed parameter, one taking - the borrowed handle and one without it (rejected: one `borrowing T?` signature, which - makes a caller holding a plain handle move it into an optional and back; a generated - `Borrowed` view built at each call). Wrapping a borrowed handle in an optional consumes - it. moq's items have one such parameter each; refuse past a small bound at the item's span. - Each present one is labelled by its name, as a defaulted argument is, so overloads leaving - out different ones differ in name: moq's `Source::follow` takes two. The bound is two, - refused at the item's span before any language is generated. -- ✅ Swift 6.4 conformances (`Equatable`, `Hashable`, `CustomStringConvertible`, - `CustomDebugStringConvertible` for noncopyable types, from - [SE-0499](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0499-support-non-copyable-simple-protocols.md)) - come in under `#if compiler(>=6.4)`. Until then `debug(c)` is `c.debugDescription` and - `"\(url)"` is `url.description`; `==` stays a static operator. An object exports no - hash, and Swift 6.4's snapshot conforms a noncopyable type to `Equatable` but not yet to - either `Custom*StringConvertible`, so only `Equatable` comes in. -- ✅ A `&mut self` call needs the handle in a `var`. -- Check on an Apple platform that `Optional<~Copyable>` metadata back-deploys to the - deployment target the generated package declares; the spike ran on Linux only. -- ✅ [moq-dev/rust2#678](https://github.com/moq-dev/rust2/pull/678)'s `&mut` in/out becomes - `inout`, exclusive at compile time, and `Option<&mut T>` becomes `inout T?`. Whichever - lands second adapts to the other. - -Public API: every generated Swift handle type and the signatures taking one (breaking), and -the generated package's swift-collections dependency. - -## Related - -- [Failed checks raise](/quest/m1/ffi-export/checks-raise.md) - the run-time checks left after this switch raise rather than trap -- [The exporter on the binding benchmark](/quest/m1/ffi-export/bench.md) - where the hold count's release-build measurement belongs From 8d574b78c283d0bb105bf2dfc7b65cba3b87ecba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 22:52:56 -0800 Subject: [PATCH 10/12] test(ffi): trap on a Swift class used after Rust moves it out The moved cases became compile-fail ones for structs, leaving the class path (an error type) without a run-time check: "refusal moved" keeps a Refusal, moves it through reasons, and reads it, which traps as closed. Co-Authored-By: Claude Opus 5.5 --- ffi/tests/api.swift | 25 +++++++++++++++++-------- ffi/tests/fixtures.rs | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ffi/tests/api.swift b/ffi/tests/api.swift index 7c90b52d6..f87003c3e 100644 --- a/ffi/tests/api.swift +++ b/ffi/tests/api.swift @@ -37,6 +37,16 @@ func text(_ data: Data) -> String { String(decoding: data, as: UTF8.self) } +/// The refusal `refuse` throws for `reason`: a class, since Swift throws it. +func refusal(_ reason: String) -> Refusal { + do { + _ = try refuse(reason) + } catch let refusal as Refusal { + return refusal + } catch {} + fatalError("refuse returned") +} + /// Each of `items` as `each` prints it, joined by commas. func listed(_ items: borrowing UniqueArray, _ each: (borrowing T) -> String) -> String { var out: [String] = [] @@ -558,14 +568,6 @@ func script() { var none: Counter? = nil print("plus \(two.plus(other: three!)) \(two.plus())") // A list and an option holding a struct beside a class, whose handle each moves. - func refusal(_ reason: String) -> Refusal { - do { - _ = try refuse(reason) - } catch let refusal as Refusal { - return refusal - } catch {} - fatalError("refuse returned") - } var pairs = UniqueArray() pairs.append(TupleCounterRefusal(Counter.new("a", 1), refusal("x"))) check( @@ -879,6 +881,13 @@ func broken(_ which: String) { _ = stray() case "lent stray": visitStray { _ in } + // A class moved into Rust, which Swift still holds: the call closes its handle. + case "refusal moved": + let kept = refusal("z") + var pairs = UniqueArray() + pairs.append(TupleCounterRefusal(Counter.new("a", 1), kept)) + _ = reasons(pairs, nil) + _ = kept.description default: check(false, "no case \(which)") } diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 7674dd40f..2fcfa8958 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -760,6 +760,7 @@ fn api_swift() { ("outlived", OUTLIVED), ("stray", "a path from Rust is not UTF-8"), ("lent stray", "a path from Rust is not UTF-8"), + ("refusal moved", "Refusal is closed"), ] { let output = Command::new(&exe).arg(case).output().unwrap(); assert!(!output.status.success(), "Swift {case} did not fail"); From 9d585d8f7e201b91fb359e752bf39af4319986fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 11 Nov 2026 10:27:45 -0800 Subject: [PATCH 11/12] fix(ffi): name every Swift tuple struct before writing any Co-Authored-By: Claude Opus 5.5 --- ffi/src/swift.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ffi/src/swift.rs b/ffi/src/swift.rs index b7c99c13a..21284ff19 100644 --- a/ffi/src/swift.rs +++ b/ffi/src/swift.rs @@ -567,6 +567,11 @@ impl<'a> Gen<'a> { }); } } + // Each named before any is written, since one tuple's fields may name another. + for c in tuples.keys() { + let name = self.claim(&self.class(c), &[], format!("the tuple `{c}`")); + self.classes.insert(c.clone(), name); + } for (c, elements) in &tuples { self.tuple(c, elements); } @@ -617,9 +622,8 @@ impl<'a> Gen<'a> { /// The struct of a tuple holding an object's struct, which a Swift tuple cannot hold: /// its fields `_0`, `_1`, ... are the tuple's elements. fn tuple(&mut self, c: &str, elements: &[Type]) { + let name = self.class(c); let types: Vec = elements.iter().map(|e| self.sw(e)).collect(); - let name = self.claim(&self.class(c), &[], format!("the tuple `({})`", types.join(", "))); - self.classes.insert(c.to_string(), name.clone()); let fields: String = (types.iter().enumerate()) .map(|(n, sw)| format!("\tpublic var _{n}: {sw}\n")) .collect(); From e118ff813b35336fb954a2e9746415e07e9a3d95 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 11 Nov 2026 10:31:24 -0800 Subject: [PATCH 12/12] test(ffi): move main's new Swift api cases onto noncopyable handles Co-Authored-By: Claude Opus 5.5 --- ffi/tests/api.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ffi/tests/api.swift b/ffi/tests/api.swift index 15030d464..81b6a1779 100644 --- a/ffi/tests/api.swift +++ b/ffi/tests/api.swift @@ -692,15 +692,18 @@ func script() { print("nexts \(next4.ip.map(String.init).joined(separator: ".")) \(next4.port) \(next6.port) \(next6.scopeId)") // serde_json's map iterator, collected as pairs of a key and its document. - let jsonSections = sections([("b", try! Json.parse("[1]")), ("a", try! Json.parse("2"))]) - print("sections \(jsonSections.map { "\($0.0) \($0.1.toText())" }.joined(separator: ", "))") + var sectionPairs = UniqueArray() + sectionPairs.append(TupleStringJson("b", try! Json.parse("[1]"))) + sectionPairs.append(TupleStringJson("a", try! Json.parse("2"))) + let jsonSections = sections(sectionPairs) + print("sections \(listed(jsonSections) { "\($0._0) \($0._1.toText())" })") // An instant only Rust makes, which each call copies out of its handle, and compares. let millis = { (d: Swift.Duration) in d.components.seconds * 1000 + d.components.attoseconds / 1_000_000_000_000_000 } let began = now() let ended = later(began, .milliseconds(1500)) print("instants \(millis(since(ended, began))) \(millis(since(began, ended))) \(began == later(began, .zero)) \(began == ended)") // tracing's level, and its filter as an optional level whose nil is `OFF`. - let verbosity = Verbosity.new(.warn) + var verbosity = Verbosity.new(.warn) print("verbosity \(spell(verbosity.filter())) \(spell(louder(verbosity.filter())))") verbosity.levelSet(.trace) print("verbosity \(verbosity.levelGet == .trace) \(spell(verbosity.filter())) \(spell(louder(verbosity.filter()))) \(spell(louder(nil)))")