Skip to content

feat(ffi): generate Swift over the C ABI, as a clang module - #406

Merged
moq-bot[bot] merged 10 commits into
mainfrom
quest/m1/ffi-export/swift
Nov 2, 2026
Merged

moq-bot[bot] merged 10 commits into
mainfrom
quest/m1/ffi-export/swift

Conversation

@kixelated

@kixelated kixelated commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Quest m1/ffi-export/swift: moq's Swift package still builds on the UniFFI fork. rust2-ffi exports moq's crates as a C ABI with C, C++, and Kotlin over it, but no Swift.

Approach

src/swift.rs writes swift/<prefix>.swift beside swift/module.modulemap, which imports the C header as the clang module <Prefix>FFI, and swift/<prefix>_atomics.h, the atomics the runtime needs (Swift's Synchronization starts at macOS 15). The generated API is the crate's pub API in Swift's idiom, named as Kotlin's:

  • Objects are final classes owning their handle: freed in deinit, or sooner by close() once no call holds it. A call after close() traps. Passing by value or a self method closes them after the call.
  • Records are Sendable structs. Enums are Swift enums, with each variant's data as associated values.
  • Std traits: PartialEq is ==, through Rust's own. Display is description, Debug is debugDescription, Default is static default(), and Clone is clone().
  • Bytes are Data, with no copy at a call's top level either way. Returned bytes lend Rust's buffer through Data(bytesNoCopy:). Bytes nested in another value are copied into the call's arena.
  • Errors: Result throws. An exported error type throws as itself, conforming to Swift.Error. Any other error type is wrapped in a generated <T>Error.
  • Futures are async throws:
    • Rust's future is polled once with no waker. Only if that poll pends does the call make the signal it parks on.
    • The signal latches a wake that lands between PENDING and parking.
    • Cancelling the task frees the future, which loses nothing, and throws CancellationError. A task cancelled before the call takes nothing, even an output ready at the first poll.
    • Each call is its own future, so it has one poller.
  • Traits are Sendable protocols, held in a box that Rust releases when it drops the implementation.
  • Closures are non-escaping @Sendable function types, called through a trampoline that captures nothing.
  • Drivers run from the caller's own loop. The waker is a @Sendable () -> Void, and moq/moq.swift polls moq's origin driver with a Waiter until the origin's last owner closes.
  • Contract: <prefix>_contract() and <PREFIX>_CONTRACT are checked once, at the first call into the library. A mismatch traps.

Tests

  • tests/api.swift runs the shared script, and its transcript matches native Rust line for line.
  • The generated file builds in Swift 6's language mode with warnings as errors. It also type-checks in Swift 5's mode, which moq's package uses.
  • The futures checks cover:
    • a cross-thread send;
    • a resume on the task's executor and never inside the wake;
    • a wake during the poll;
    • a cancel between wake and poll that loses nothing;
    • a cancel of a parked receive;
    • a cancel before the first poll, with a payload ready;
    • a future ready with None;
    • a handle closed while a call awaits.
  • The owned checks show that releasing frees Rust's bytes and handles at once.
  • Each broken contract traps or aborts with its message, and a mismatched contract traps.
  • The merge gate runs it with nixpkgs' Swift. The default dev shell gains swift wherever nixpkgs builds it, so not on aarch64-linux, where the test is ignored and points at quest/m2/uniffi-swift-arm.md.
  • The macOS entry of the nightly ffi-cpp job adds just ffi test swift && just ffi moq-swift with Apple's toolchain, as C++ runs there.
  • moq/moq.swift runs moq.c's script, including the QUIC session and the driver. It passes locally.
  • The fixture gains four edge cases:
    • a variant named Init, which is Swift's initializer and also the C union member's name (the moq export hit it);
    • an async fn ready with None (also hit by the moq export);
    • extended, a Duration sum that checks values past Int64 seconds up to Rust's Duration::MAX;
    • visit_stray, a closure lent a non-UTF-8 path, which traps.

Choices made without asking

  • Deployment floor: macOS 13 and iOS 16, for Swift.Duration, the only exact Swift type for Rust's Duration. moq's package says macOS 12.3, so its switch raises it. iOS 16 is already its floor. The alternative, gating every declaration that touches Duration behind @available, spreads through every record holding one. Settled by the maintainer on 2026-11-01: ✅ raise to macOS 13 and iOS 16; keep 12.3 and gate Duration; generate an own Duration type.
  • Parameters have no labels (Counter.new("c", 5)), so a call reads as Rust's. Records keep a labeled memberwise init.
  • Exported error types throw as themselves (catch let e as Api.Error), rather than Kotlin's <E>Exception wrapper. Only non-exported error types (String, bounds) get a <T>Error wrapper.
  • Records compare through Rust's PartialEq, and only when Rust has one, so == keeps Rust's semantics.
  • SocketAddr is the runtime's own struct (IP bytes, port, flow label, scope), since Foundation has no socket address value type. Rust checks the family for SocketAddrV4 and SocketAddrV6.
  • A member named init takes a trailing _, as Kotlin's reserved members do.

Measured

just ffi bench with moq's frame path, 100 B frames, in ns per frame. Each cell is the median of 3 rounds, on x86_64 on a busy shared host (load average 32 to 55), so the numbers are noisy:

moq-c C C++ Kotlin Swift
write, fresh timestamp 285 292 291 620 432
write, reused timestamp 277 283 281 331
handle call 7.1 7.2 12.6 10.7
write then read, ready 496 507 997 702
payload read 15 16 83 112

The payload read is Data(bytesNoCopy:) over Rust's buffer, so it never copies. Its cost is Foundation's storage allocation per read.

Generated size for moq:

raw gzip -9
moq.swift 1,596,487 B 256,443 B
moq.swift without doc comments 881,327 B 83,870 B
fork's Swift 684 KiB
moq.kt, for comparison 1,649,098 B 269,127 B

As with Kotlin, the export covers moq's crates whole, with every doc comment, rather than moq-ffi's hand-written surface. Compiling moq.swift as a module takes about 100 s at -Onone on this host.

Impact

  • New: every export writes swift/<prefix>.swift, swift/module.modulemap, and swift/<prefix>_atomics.h.
  • Breaking, in ffi/MIGRATE.md:
    • The runtime claims the C names <prefix>_atomic_{load,store,add,exchange,cas} and <PREFIX>_ATOMICS_H.
    • An item is an error if its Swift name clashes with another item's, or with Swift or a standard library name the Swift uses unqualified (such as Data).
    • So is a member whose full Swift name clashes with another member's.
  • flake.nix: the default shell adds swift where nixpkgs builds it.
  • ffi/justfile: adds moq-swift. moq and bench run the Swift too, with a stated skip on aarch64-linux.
  • nightly.yml: the macOS ffi-cpp entry runs the Swift checks.
  • Consumer: moq's swift/ package, through moq's uniffi-fork switch.

Alternatives

  • Swift's C++ interop over <prefix>.hpp: it would reuse the C++ header, but Swift's C++ interop is still incomplete for templates and move-only types, and the C ABI is the stable base every other generator uses.
  • Atomics through Synchronization.Atomic: it needs macOS 15 and iOS 18, far above moq's floor.
  • ManagedBuffer for the signal: Swift 6 makes its subclasses non-Sendable, so a plain class owning an allocated word is simpler.

Follow-ups

  • quest/m2/uniffi-swift-arm.md now also asks to drop rust2-ffi's aarch64-linux Swift skips once nixpkgs builds Swift there.
  • The quest file is deleted: this completes it.

Review

quest review, Codex gpt-6.1-sol at medium effort, 3 rounds. Clean at head e702d634cef7a24bf9807b4f2c6546c1023dc668.

  • Round 1 (5 findings):
  • Round 2 (2 findings):
    • Fixed: error-wrapper names that collapse under PascalCase now fail as a collision.
    • Fixed: an item named Swift is refused. Refuted in part: no span and no rejection test, matching Kotlin's naming errors.
  • Round 3: no findings.

🤖 Generated with Claude Code

kixelated and others added 9 commits November 1, 2026 14:26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut reformatting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion, and check lent paths from Swift

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…collide

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Swift bindings feat(ffi): generate Swift over the C ABI, as a clang module Nov 2, 2026
@kixelated
kixelated marked this pull request as ready for review November 2, 2026 01:29
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b0cfa466-11f0-4cfa-802f-ca1622d916cc
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@kixelated
kixelated enabled auto-merge (squash) November 2, 2026 01:32
@moq-bot

moq-bot Bot commented Nov 2, 2026

Copy link
Copy Markdown

quest land: this pull request conflicts with a pull request queued ahead of it, in these files:

  • #407: .github/workflows/nightly.yml, ffi/MIGRATE.md, ffi/README.md, ffi/justfile, ffi/src/glue.rs, ffi/src/main.rs, ffi/tests/fixtures.rs

Once one of them lands, this one conflicts with the base and is ejected when its turn comes. Resolve the overlap before then, such as by coordinating with that author, or by merging the base once it lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant