Skip to content

feat(ffi)!: Swift handles are noncopyable structs - #697

Draft
kixelated wants to merge 8 commits into
mainfrom
quest/m1/ffi-export/swift-noncopyable
Draft

kixelated wants to merge 8 commits into
mainfrom
quest/m1/ffi-export/swift-noncopyable

Conversation

@kixelated

@kixelated kixelated commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Claims quest/m1/ffi-export/swift-noncopyable.md.

Summary

rust2-ffi's Swift generator now emits each object that is not Copy and not thrown as an error as a ~Copyable struct that owns its handle and frees it in deinit. Swift's compiler now checks each move and borrow Rust makes:

  • self, &self, &mut self and &mut T become consuming, borrowing, mutating and inout.
  • A tuple holding a handle becomes a generated @frozen struct, such as TupleStringCounter, with fields _0, _1, and so on.
  • An array of handles becomes swift-collections' UniqueArray.
  • An Option<&T> of a handle becomes one overload with the parameter and one without, each present parameter labelled by its name: clamped(), clamped(low:), clamped(high:), clamped(low:high:). More than two per function is a compile error at the item's span, which the driver emits before any language is generated (ffi/fixtures/reject's among).
  • Option<&mut T> becomes inout T?.
  • The per-handle atomic hold count is gone from structs. Classes (Copy objects and errors) keep it.
  • Under #if compiler(>=6.4), a struct with PartialEq conforms to Equatable.

ffi/tests/api.swift now has four compile-fail cases: moved, boxed moved, lent moved and lent kept. ffi/tests/fixtures.rs builds each case with -D<CASE> and checks swiftc's diagnostic. The rest of the script still matches the native transcript. just ffi moq-swift passes with moq's Swift adapted. Locally, that run also needed #693's flac md5 exclusion, because main's pinned moq fails without it (see Waiting).

Public API impact and migration

This breaks every generated Swift handle type and every signature that takes one:

  • Bind an object you call a &mut self method on with var, not let. Pass &mut T as &x.
  • An object can't be copied. Moving one twice, or keeping a lent one past its call, is a compile error. Share one through a class holder, as ffi/moq/moq.swift's Held and Slot do.
  • "\(x)" and debug(x) become x.description and x.debugDescription, because Swift 6.3 conforms no noncopyable type to CustomStringConvertible.
  • A list of handles is a UniqueArray<T>. A tuple holding one is a TupleAB struct: read t._0, or destructure with let a = t._0; let b = t._1.
  • f(x) with an Option<&T> becomes f(name: x), or f() for None.
  • The generated module imports BasicContainers from swift-collections 1.7.5, so a consumer must build and link it. just ffi swift-collections builds the static libraries from a pinned rev.

Hold count benchmark

Release library, -O, median of 5 interleaved runs, on a loaded host (load average 28.7 on 24 cores):

get() create, call, free
Struct, no hold count 10.2 ns 29.6 ns
Hold count restored 18.2 ns 48.7 ns

The gain is confirmed, so the hold count is dropped.

Choices made without asking

  • Tuple field names _0, _1: matches Rust's .0, and a leading _ keeps 0 a valid identifier.
  • Lending to a closure: a lent &T becomes a struct over the borrowed handle, and the struct ends with discard self after the call, so Swift never frees a handle Rust still owns. A lent &mut T traps with "a lent handle was replaced" if the closure assigned a new value over it, which Rust cannot express. The maintainer chose the trap over generating an assign through C, since no moq item needs it.
  • Always import BasicContainers: a conditional import would make the module's shape depend on which types it exports.
  • swift-collections from a just recipe that clones at a pinned rev and builds with swiftc, not Nix. macOS CI runs without Nix, so both platforms use the same path.
  • The Option<&T> bound is 2, refused at the item's span through a Swift-only check the driver runs, rather than in collect for every language, where an optional borrow needs no overloads. moq's Source::follow takes two.
  • Labelled optional borrows: each present one is labelled by its name, as a defaulted argument is, so overloads leaving out different ones differ in name. Without labels, follow(rel) and follow(instance) were both follow(_:).
  • Only Equatable under 6.4: objects export no hash, and the Swift 6.4 snapshot does not yet conform a noncopyable type to either Custom*StringConvertible, despite SE-0499.
  • @frozen tuple structs, so that another module can move each element out.

Gaps

The Swift generator's other refusals are still messages without a span, out of this PR's scope:

  • an item named as the Swift runtime names something (claim, ffi/src/swift.rs);
  • two items with one top-level Swift name (claim);
  • two members of a type with one full Swift name (distinct).

The two refusals this PR adds, more optional borrowed objects than the bound and an error holding an object Swift cannot copy, are spanned (ffi/fixtures/reject's among and wrapped).

Waiting

  • The Apple platform check: Optional<~Copyable> back-deploy and swift-collections at the macOS 13 target. The nightly's macOS job runs just ffi moq-cpp before its Swift step, and that export fails on main's pinned moq (mux::codec::flac::Config::md5, [u8; 16]) until moq-dev/rust2#693 lands. Once it does, merge main here and run gh workflow run nightly.yml --ref quest/m1/ffi-export/swift-noncopyable.

Models

  • Size: L
  • Implementer: Claude Code, Opus 5.5, high effort

(Written by Opus 5.5 with high)

🤖 Generated with Claude Code

Review

Rewritten by quest review after each round.

  • Reviewer: Codex, gpt-6.1-sol, high effort
  • Session: 01a1c773-b96a-74f2-a925-35a624061367, round 2
  • Head: 19c657e
  • Base: origin/main at efbfa66
  • Score: 10/10, Inspection of the incremental changes confirms both fixes and reveals no new blocking issues; builds and tests were not run.
  • Findings: none
  • The last round's findings, at e8c8535:
    • Fixed: medium, ffi/src/swift.rs:290: Borrow mixed UniqueArray elements during handle cleanup; ffi/src/swift.rs:1073 now borrows noncopyable option contents through Native.option and indexes UniqueArray elements during cleanup, with regression coverage in ffi/tests/api.swift:551.
    • Fixed: medium, ffi/src/swift.rs:1379: Emit noncopyable-error refusals with the function's source span; The refusals() pass now reports noncopyable errors at function.span before generation, removes the unspanned generator error, and covers wrapped in ffi/fixtures/reject/expected.txt.

@pullfrog

pullfrog Bot commented Nov 10, 2026

Copy link
Copy Markdown

Pullfrog  | Review this ➔

kixelated and others added 5 commits November 10, 2026 12:26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…copyable handles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adapts #678's lent returns and inout to noncopyable structs: Option<&T> becomes
an overload per optional borrowed parameter, and Option<&mut T> becomes inout T?.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds 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 <noreply@anthropic.com>
@kixelated kixelated changed the title Swift handles are non-copyable feat(ffi)!: Swift handles are noncopyable structs Nov 10, 2026
…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 <noreply@anthropic.com>
…efuse 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant