From 083f77b2a1425184b265ceb84d27735c8a67ce25 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 9 Nov 2026 20:42:50 -0800 Subject: [PATCH] quest: plan compile-time ownership for the Swift and C++ bindings Follow-ups of the compile-checks spike (#688). Co-Authored-By: Claude Opus 5.5 --- quest/m1/ffi-export/README.md | 2 + quest/m1/ffi-export/cpp-attributes.md | 36 ++++++++++++++ quest/m1/ffi-export/swift-noncopyable.md | 60 ++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 quest/m1/ffi-export/cpp-attributes.md create mode 100644 quest/m1/ffi-export/swift-noncopyable.md diff --git a/quest/m1/ffi-export/README.md b/quest/m1/ffi-export/README.md index 9b3c46b5f..fdddcf347 100644 --- a/quest/m1/ffi-export/README.md +++ b/quest/m1/ffi-export/README.md @@ -96,4 +96,6 @@ names and defaults, and fails the export on a public item it cannot export. - [Types that are not Send export](/quest/m1/ffi-export/not-send.md) - a `!Send` object or future is checked by thread, with an owner thread where a language has none - [No name clash fails an export](/quest/m1/ffi-export/names.md) - every generator names every item by rule, so no error tells a crate to rename or exclude one - [The glue builds what the export checked](/quest/m1/ffi-export/reproducible.md) - the generated crate carries its lockfile, and a discriminant or nightly-only item it cannot build is refused at export +- [Swift handles are non-copyable](/quest/m1/ffi-export/swift-noncopyable.md) - a call on a moved handle or a lent handle kept past its call is a Swift compile error, not a trap +- [C++ checks ownership at compile time](/quest/m1/ffi-export/cpp-attributes.md) - typestate and lifetime attributes and `api::borrow` make Clang refuse what the run-time checks catch - [The exporter on the binding benchmark](/quest/m1/ffi-export/bench.md) - committed call, throughput, and size numbers per language against the Rust floor, whose report plans the speed work diff --git a/quest/m1/ffi-export/cpp-attributes.md b/quest/m1/ffi-export/cpp-attributes.md new file mode 100644 index 000000000..088436e94 --- /dev/null +++ b/quest/m1/ffi-export/cpp-attributes.md @@ -0,0 +1,36 @@ +# [S] C++ checks ownership at compile time + +## Goal + +[rust2-ffi](/ffi/README.md)'s C++ header marks handles with Clang's typestate attributes, +checked by `-Wconsumed`; gives returned views `[[clang::lifetimebound]]` and what a callable +keeps `[[clang::lifetime_capture_by]]`; and lends a `&mut` object as `api::borrow`, which +neither moves nor converts. Each sits behind a macro that is empty on GCC and MSVC, so a +consumer that does not enable the warnings pays nothing, and the run-time lend check stays +as a backstop. Proven by `ffi/tests/api.cpp`: the 6 ownership cases the spike found fail +under `-Wconsumed -Werror`, a view of a temporary buffer fails under `-Wdangling`, and the +script still matches the native transcript at C++17, 20, and 23 and builds with GCC. + +## 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 and test diffs: + +- ✅ Adopt all three: typestate attributes, lifetime attributes, and `api::borrow` + (rejected: the attributes without `api::borrow`; no change). The cost to callers in the + spike was one lambda rewritten to call through `->`. +- ✅ m1, in the exporter epic (rejected: m2), since `api::borrow` changes the C++ API, + which is cheapest before moq adopts the bindings. +- `std::move(*borrow.operator->())` still compiles, which is why the run-time check stays. +- [moq-dev/rust2#678](https://github.com/moq-dev/rust2/pull/678)'s `&mut` in/out takes or + yields `api::borrow`; its `Option<&T>` pointers get nothing from typestate and keep + the run-time check. Whichever lands second adapts to the other. + +Public API: the C++ signatures that lend a `&mut` object (breaking), and the header's +attribute macros. + +## Related + +- [Swift handles are non-copyable](/quest/m1/ffi-export/swift-noncopyable.md) - the same spike's Swift half diff --git a/quest/m1/ffi-export/swift-noncopyable.md b/quest/m1/ffi-export/swift-noncopyable.md new file mode 100644 index 000000000..480c2ab43 --- /dev/null +++ b/quest/m1/ffi-export/swift-noncopyable.md @@ -0,0 +1,60 @@ +# [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. 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. +- 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`. +- 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 +- [C++ checks ownership at compile time](/quest/m1/ffi-export/cpp-attributes.md) - the same spike's C++ half