Skip to content

iroh default mobile transport: design + green Swift FFI spike - #5735

Merged
lawrencecchen merged 6 commits into
mainfrom
feat-ios-iroh
Jun 10, 2026
Merged

lawrencecchen merged 6 commits into
mainfrom
feat-ios-iroh

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Design plus gating-risk spike for making iroh the default cmux iOS-to-Mac transport, with Tailscale demoted to an opt-in fallback. PR 1 of the stacked plan in plans/feat-ios-iroh/DESIGN.md: no app or runtime changes, nothing links the spike code, no reload needed.

Design (plans/feat-ios-iroh/DESIGN.md): substrate swap, not a protocol rewrite. The existing length-prefixed mobile-host protocol rides one iroh QUIC bi-stream dialed by EndpointId instead of TCP-over-Tailscale. Covers the Mac listener seam (MobileHostByteConnection), the phone CmxByteTransport lane, the iroh route kind in the device registry's opaque routes jsonb (zero schema change), QR staying first-trust-only, the Tailscale opt-in toggle with rollout compat, E2E story (iroh's QUIC raw-public-key TLS replaces the planned Noise IK layer on this lane, plus EndpointId pinning in the first lane so a substituted route can never receive a Stack token), Keychain key custody, relay strategy (n0 now, self-hosted iroh-relay later), iOS background/battery policy, and reconciliation with the hive design. Delivery is 5 stacked PRs; the Rust-toolchain-on-every-CI-runner change is why packaging (PR 2) is not bundled here.

Spike (experiments/iroh-swift-ffi-spike/): green. The official uniffi bindings (https://github.com/n0-computer/iroh-ffi) are archived, so the spike is what n0 recommends, a ~440-line Rust staticlib over iroh 1.0.0-rc.1 exposing a minimal blocking C API consumed from Swift via a bridging header. Proof: an arm64 iOS-simulator process (iPhone 17, iOS 26.4) dialed a macOS process by EndpointId alone through n0 relays/discovery and round-tripped bytes, 0.50 to 1.04s connects, clean exits both sides. Measured post-link app delta is about +7.7 MB per architecture slice. No binaries committed; out/ and rust/target/ are gitignored. README records bindings decision, versions, build steps, proof transcript, and sizes.

Autoreview (codex, base origin/main) clean after fixing its one finding: connection close now drains the finished send stream (stopped() with a 5s bound) before Connection::close, so a final accepted send() cannot be silently dropped.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Low Risk
Changes are limited to an experiment tree and design doc; production apps and CI toolchain are untouched until later PRs.

Overview
Adds PR 1 of the iroh mobile-transport stack: a committed design (plans/feat-ios-iroh/DESIGN.md) and a self-contained green spike under experiments/iroh-swift-ffi-spike/—no Mac/iOS app or runtime wiring.

The design records the decision to make iroh the default iOS→Mac byte substrate (one QUIC bi-stream under the existing length-prefixed mobile protocol) while Tailscale becomes opt-in, and outlines stacked follow-ups: xcframework packaging, CmxIrohByteTransport, Mac MobileHostByteConnection, EndpointId pinning, Keychain keys, and rollout toggles.

The spike replaces archived iroh-ffi with a small Rust staticlib + C header on iroh 1.0.0-rc.1 (bind / dial-by-EndpointId / CmxAttachRoute-shaped route JSON / stream recv-send), plus build.sh and a Swift CLI that proved iOS simulator → macOS echo over n0 relays (~0.5–1s connect, ~+7.7 MB linked slice). Notable FFI detail: graceful close waits on stopped() after finish() so accepted sends are not dropped.

Reviewed by Cursor Bugbot for commit 8472a72. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Design doc and a green Swift/Rust FFI spike to adopt iroh QUIC as the default iOS-to-Mac transport, with Tailscale as an opt-in fallback. This adds the design and a proof harness only; no app/runtime changes or linked code.

  • New Features
    • Protocol unchanged; it now rides one iroh QUIC bi-stream dialed by EndpointId. Publish an iroh route (registry jsonb) with priority 5 so it beats Tailscale; QR flow stays first-trust-only.
    • Security: QUIC raw-public-key TLS on this lane; EndpointId pinning at first trust; Mac/phone keys in Keychain; Stack tokens only sent over a pinned EndpointId.
    • Delivery: 5 stacked PRs; this PR is design + spike. Packaging and the phone/host lanes land next behind a flag.
    • Spike (experiments/iroh-swift-ffi-spike/): minimal Rust staticlib over iroh@1.0.0-rc.1 exposing a small blocking C API; Swift harness shows iOS-sim dialing a Mac by EndpointId via n0 relays and echoing bytes (0.5–1.0s connects); ~7.7 MB per-arch post-link delta; drains finished stream before connection close(); no binaries committed.

Written for commit 8472a72. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Experimental spike introducing iOS-to-Mac connectivity via iroh transport with route discovery and connection management capabilities.
  • Documentation

    • Added comprehensive design documentation and implementation guides for the iroh-based mobile connectivity architecture, establishing iroh as default transport with Tailscale as optional fallback.
  • Chores

    • Added build automation and project scaffolding for the experimental implementation.

lawrencecchen and others added 6 commits June 9, 2026 16:45
Minimal Rust staticlib (iroh 1.0.0-rc.1) exposing a blocking C API:
bind endpoint, dial by EndpointId via n0 relays/discovery, one bi-stream
send/recv. Swift CLI harness with listen (echo) and dial (round-trip
proof) modes. build.sh builds aarch64-apple-darwin and
aarch64-apple-ios-sim variants; artifacts are gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- link Network.framework for the ios-sim target (netdev nw_path_monitor)
- map clean peer close (application code 0) to end-of-stream in recv
- line-buffer harness stdout so orchestration can read the endpoint id
- README records bindings decision (official iroh-ffi is archived, n0
  recommends a custom wrapper), versions, build steps, proof transcript,
  and the ~7.7MB per-slice binary delta

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Substrate swap for the existing length-prefixed mobile-host protocol:
iroh QUIC dial-by-EndpointId as the default iOS-to-Mac transport,
Tailscale/LAN demoted to an opt-in fallback toggle. Covers the Mac
listener seam (MobileHostByteConnection), the phone CmxByteTransport
lane, registry route publication, E2E story (QUIC raw-public-key TLS
replaces the Noise IK plan on this lane), Keychain key custody, relay
strategy (n0 now, self-host later), iOS background/battery policy,
hive-design reconciliation, and a 5-PR stacked delivery plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CmxAttachTicket.preferredRoute sorts ascending and lower wins. The Mac
publishes debugLoopback at 0 and Tailscale at 10+; the spike's route
JSON claimed 20, which would have lost to Tailscale, contradicting the
design's iroh-by-default ordering. 5 sits below Tailscale (default) and
above debugLoopback (DEBUG/simulator keeps the loopback mock host).
Re-ran the cross-platform proof after the change: iOS-sim dialed the
Mac by EndpointId, 46 bytes echoed, 1.04s connect, rc=0 both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sharpen the security section: the real threat on a substituted route is
Stack-token exfiltration (the phone sends its bearer token on every
RPC), and iroh is the lane that can close it because the channel is
cryptographically bound to the dialed EndpointId. So pinning moves from
'defense in depth later' into PR 3/4: pin at first trust in
MobilePairedMacStore (QR = proximity, registry auto-pair = TOFU),
refuse to send Stack tokens to a non-matching EndpointId, and surface
EndpointId changes for explicit re-trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding (autoreview P2): finish() only queues the FIN plus
buffered stream data, while Connection::close is immediate and abandons
buffered data, so a final frame that send() already accepted could be
dropped by close(). Wait on SendStream::stopped() (peer acked all
finished data) with a 5s bound before closing, so a vanished peer
cannot wedge close. Re-ran the cross-platform proof: iOS-sim dial rc=0,
mac listener rc=0, 0.50s connect, no drain stall (2s wall total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 10, 2026 12:46am
cmux-staging Ready Ready Preview, Comment Jun 10, 2026 12:46am

@coderabbitai

coderabbitai Bot commented Jun 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a complete Rust FFI spike exposing iroh's endpoint and connection APIs for iOS-Mac communication. A C header and Rust library implement a minimal blocking interface, tested by a Swift harness supporting listen and dial modes, packaged with build automation for macOS and iOS simulator targets. Design documentation outlines the planned iOS↔Mac transport integration using iroh as the default CMUX transport.

Changes

iroh Swift FFI Spike

Layer / File(s) Summary
FFI contract and Rust foundation
experiments/iroh-swift-ffi-spike/include/cmux_iroh_ffi.h, experiments/iroh-swift-ffi-spike/rust/Cargo.toml, experiments/iroh-swift-ffi-spike/rust/src/lib.rs (imports, runtime, structs, helpers)
C header declares opaque endpoint and connection types with blocking error-buffer-based functions. Rust Cargo.toml configures staticlib build with iroh and tokio dependencies. lib.rs initializes a shared multi-thread Tokio runtime and defines opaque handle structs; error buffer, string conversion, and string free helpers move data safely across the FFI boundary.
Endpoint bind, online, and accept
experiments/iroh-swift-ffi-spike/rust/src/lib.rs (bind, id, route_json, online, accept)
cmux_iroh_endpoint_bind constructs an iroh Endpoint using the N0 preset with a fresh SecretKey, optionally enabling relays and ALPN. cmux_iroh_endpoint_id and cmux_iroh_endpoint_route_json return heap-allocated C strings (endpoint id and a JSON route object with addresses and relay URL). cmux_iroh_endpoint_online waits for the endpoint to reach online status within a timeout. cmux_iroh_endpoint_accept accepts one incoming connection and opens its first bidirectional stream.
Connect, send, receive, and close
experiments/iroh-swift-ffi-spike/rust/src/lib.rs (connect, recv, send, connection_close, endpoint_close)
cmux_iroh_endpoint_connect dials a remote endpoint by EndpointId with optional direct address hints and relay URL, opening a bidirectional stream within a timeout. cmux_iroh_connection_send writes all bytes to the send stream or returns -1 on error. cmux_iroh_connection_recv reads up to buffer capacity, returning bytes read, 0 on clean stream end, or -1 on error. cmux_iroh_connection_close gracefully finishes the send half and closes the connection. cmux_iroh_endpoint_close closes the endpoint handle.
Swift harness, build, and CLI
experiments/iroh-swift-ffi-spike/build.sh, experiments/iroh-swift-ffi-spike/swift/main.swift
build.sh compiles the Rust staticlib for the selected target (macos or ios-sim), then links it with the Swift harness using xcrun swiftc. swift/main.swift implements listen and dial modes: listen binds an endpoint, prints metadata, accepts a connection, and echoes bytes back; dial binds, connects by EndpointId, sends payload, receives the echo, and verifies it matches. CLI parsing dispatches to the chosen mode with error handling and resource cleanup.
Documentation and configuration
experiments/iroh-swift-ffi-spike/.gitignore, experiments/iroh-swift-ffi-spike/README.md, plans/feat-ios-iroh/DESIGN.md
.gitignore ignores out/ and rust/target/ build artifacts. README documents spike rationale (iOS-to-Mac via cmux + QUIC stream), approach (minimal blocking C FFI), build/proof steps, binary size measurements, and deferred gaps. DESIGN.md specifies the planned iOS↔Mac transport integration: iroh becomes default CMUX transport with optional Tailscale fallback; describes architecture, onboarding, security model (QUIC raw-public-key TLS, EndpointId pinning, token-gating), relay strategy, and delivery plan.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A Swift spike hops through iroh's gate,
Rust and C in FFI dance,
Endpoints dial, streams echo, fate—
iOS and Mac in queued enhance. ✨

🚥 Pre-merge checks | ✅ 20 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: introducing iroh as the default mobile transport via design documentation and a working Swift FFI spike.
Description check ✅ Passed The description covers most required sections: a detailed summary of changes, mentions testing/proof of the spike working, explains reasoning, but lacks explicit documentation of local testing steps and bot review triggers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Only Swift file changed is spike code in experiments/ with no type definitions, service protocols, or shared mutable Sendable references; no production Swift files modified.
Cmux Swift Blocking Runtime ✅ Passed PR adds Swift only in experiments/ as explicitly marked test/spike code. No blocking primitives (semaphores, Task.sleep, main-queue sync, locks) used. Qualifies under allowed test-only exception.
Cmux Expensive Synchronous Load ✅ Passed Only experimental Swift in experiments/iroh-swift-ffi-spike/swift/main.swift added; rule covers production changes only.
Cmux Cache Substitution Correctness ✅ Passed PR contains experimental spike code and design documentation only. No production Swift/TypeScript/JavaScript changes present. Actual app implementation deferred to future PRs per delivery plan.
Cmux No Hacky Sleeps ✅ Passed PR uses tokio::time::timeout() cancellation-aware abstraction with bounded deadlines for network operations and graceful close—not hacks to paper over races.
Cmux Algorithmic Complexity ✅ Passed All code in this PR is either experimental spike code (in experiments/ directory) or documentation, not production code subject to algorithmic complexity rules.
Cmux Swift Concurrency ✅ Passed Spike/harness code (main.swift) has no DispatchQueue, Task, Combine, or completion-handler patterns—only synchronous blocking FFI calls intentionally on CLI main thread.
Cmux Swift @Concurrent ✅ Passed Swift file contains only synchronous functions with no async/await, @concurrent annotations, or actor isolation attributes; blocking FFI calls intentional for CLI spike code.
Cmux Swift File And Package Boundaries ✅ Passed Swift file at experiments/iroh-swift-ffi-spike/swift/main.swift is a 124-line prototype spike outside the rule's scanned directories, matching the "prototypes" allowed exemption.
Cmux Swift Logging ✅ Passed Swift code is a CLI harness in experiments/ for proof testing, not production code. Its print() statements are intended user-facing CLI output, which is explicitly allowed by the logging rules.
Cmux User-Facing Error Privacy ✅ Passed Spike code in experiments/ and design docs in plans/ fall under allowed cases: tests and docs not shown to end users. Rule scope is production changes only.
Cmux Full Internationalization ✅ Passed Spike prototype in experiments/ directory only, no production app changes, no xcstrings/i18n modifications, developer output allowed.
Cmux Swiftui State Layout ✅ Passed PR contains no SwiftUI code—only a Foundation-based CLI harness without state decorators, View structs, or layout patterns. Check is inapplicable to non-SwiftUI changes.
Cmux Architecture Rethink ✅ Passed PR contains spike code in experiments/ and design document only; no production Swift code changes violating swift-architectural-rethink.md rules.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Swift code is a CLI test harness with no window creation; allowed as test-only fixture per the rule.
Cmux Source Artifacts ✅ Passed All changed files are intentional source, configs, or docs. No build output, binaries, caches, or artifacts. Cargo.lock is appropriate for staticlib. .gitignore correctly excludes build artifacts.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-iroh

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

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8472a72570

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

3. Phone dials by EndpointId. n0 discovery finds the Mac through its home relay; QUIC holepunches to a direct path when possible, relay carries traffic otherwise. No VPN, no LAN requirement, no QR.
4. Every RPC still carries the Stack access token; the Mac verifies same-account server-side. The registry is rendezvous, never authority (unchanged from https://github.com/manaflow-ai/cmux/pull/5626).

QR pairing remains exactly what it is today: first-trust UX and the fallback when the registry is unreachable. The QR payload's routes list simply includes the iroh route, so a QR pair also yields an EndpointId the phone can keep dialing from anywhere.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep QR payloads decodable by old clients

During the rollout case where a new Mac publishes both iroh and Tailscale routes but an older iOS app scans the QR fallback, adding the iroh route to the QR ticket makes the existing attach decoder fail before it can choose the Tailscale route: CmxAttachTicketInput.decode JSON-decodes the whole CmxAttachTicket first, and older builds whose CmxAttachTransportKind enum lacks .iroh will throw on the unknown route kind. The registry path may skip unknown routes, but the QR/attach URL path does not, so the fallback described here needs either legacy-safe QR filtering or a tolerant route decoder before including iroh in QR payloads.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Design doc and gating-risk spike (PR 1 of 5) for making iroh the default cmux iOS-to-Mac transport, demoting Tailscale to an opt-in fallback. No production app or runtime code is changed; the spike lives entirely in experiments/ and is not linked by anything.

  • plans/feat-ios-iroh/DESIGN.md: covers the full substrate-swap architecture (Mac listener seam, phone transport lane, device-registry route format, QR first-trust, Tailscale rollout compat, E2E security via QUIC raw-public-key TLS + EndpointId pinning, Keychain key custody, relay strategy, iOS battery policy), plus a 5-PR delivery plan.
  • experiments/iroh-swift-ffi-spike/: ~440-line Rust staticlib over iroh 1.0.0-rc.1 exposing a minimal blocking C API consumed from Swift via a bridging header; proof transcript shows an iOS-simulator process dialing a macOS process by EndpointId alone and round-tripping bytes (0.5–1.04 s connects, both sides clean exit); out/ and rust/target/ are gitignored, Cargo.lock is intentionally committed for reproducibility.

Confidence Score: 4/5

Safe to merge as-is; no production code is touched and the spike is not linked by anything. The one issue to address before PRs 3/4 adopt this pattern is in the close logic.

The stopped() call in cmux_iroh_connection_close is described in its comment as waiting for 'peer acknowledges receipt of all finished stream data,' but Quinn's SendStream::stopped() resolves only on a STOP_SENDING frame from the peer — not on QUIC ACKs. In the normal happy-path close (remote reads all data and closes cleanly without sending STOP_SENDING), every teardown would block the full 5-second bound. The spike works because the dialer's Connection::close sends CONNECTION_CLOSE, which cancels the listener's pending stopped() immediately — a two-sided-CLI coincidence, not a general guarantee. This pattern and its comment are explicitly documented as the basis for production PRs 3 and 4, making the incorrect framing likely to carry forward into the real transport teardown path.

experiments/iroh-swift-ffi-spike/rust/src/lib.rs — specifically the cmux_iroh_connection_close function and its close-drain strategy, which the design doc says will graduate directly into the production FFI in PR 2/3.

Important Files Changed

Filename Overview
experiments/iroh-swift-ffi-spike/rust/src/lib.rs ~440-line Rust staticlib over iroh 1.0.0-rc.1; blocking C API for bind/connect/accept/recv/send/close. Close logic uses stopped() with misleading comment about its semantics — see inline comment.
plans/feat-ios-iroh/DESIGN.md Thorough design doc covering transport swap, security model (EndpointId pinning, TOFU, Keychain custody), relay strategy, onboarding flow, iOS background policy, Tailscale fallback rollout, and 5-PR delivery plan. No production code changes in this PR.
experiments/iroh-swift-ffi-spike/swift/main.swift CLI spike harness (listen + dial modes); blocking calls on main thread are explicitly documented as intentional for a CLI tool, not production app code.
experiments/iroh-swift-ffi-spike/build.sh Clean build script; correctly separates macOS vs iOS-sim framework deps (CoreWLAN only on macOS), no hacky sleeps, set -euo pipefail guarded.
experiments/iroh-swift-ffi-spike/include/cmux_iroh_ffi.h Minimal C bridging header; types match Rust FFI exports, opaque struct forward-declarations are correct, intptr_t return for recv is ABI-equivalent to Rust isize on arm64.
experiments/iroh-swift-ffi-spike/rust/Cargo.toml Staticlib crate, publish = false, size-optimized release profile (LTO + strip debuginfo + opt-level s). Intentionally pinned to iroh 1.0.0-rc.1.
experiments/iroh-swift-ffi-spike/.gitignore Correctly gitignores out/ (compiled harness binaries) and rust/target/ (Cargo build cache); no build artifacts committed.
experiments/iroh-swift-ffi-spike/README.md Well-documented spike README: covers bindings decision rationale, version pins, build steps (including rustup target add), proof transcript, binary-size measurements, and explicit spike-level gaps pointing to the design doc.

Sequence Diagram

sequenceDiagram
    participant SwiftHarness as Swift Harness (dial)
    participant RustFFI as Rust FFI staticlib
    participant IrohNet as iroh / n0 relays
    participant MacListener as Swift Harness (listen)

    SwiftHarness->>RustFFI: "cmux_iroh_endpoint_bind(enable_relay=true)"
    RustFFI->>IrohNet: Endpoint::builder(presets::N0).bind()
    MacListener->>RustFFI: "cmux_iroh_endpoint_bind(accept_connections=true)"
    RustFFI->>IrohNet: Endpoint bound + home relay acquired
    MacListener->>RustFFI: cmux_iroh_endpoint_online(30 000ms)
    RustFFI->>IrohNet: endpoint.online() wait for relay
    IrohNet-->>RustFFI: relay connected
    MacListener->>RustFFI: cmux_iroh_endpoint_accept(180 000ms)
    Note over RustFFI: blocks on accept() + accept_bi()
    SwiftHarness->>RustFFI: cmux_iroh_endpoint_connect(endpointId, 60 000ms)
    RustFFI->>IrohNet: connect(EndpointAddr) + open_bi()
    IrohNet-->>RustFFI: QUIC holepunch / relay path
    RustFFI-->>MacListener: CmuxIrohConnection (accepted)
    RustFFI-->>SwiftHarness: CmuxIrohConnection (dialed)
    SwiftHarness->>RustFFI: cmux_iroh_connection_send(payload)
    RustFFI-->>MacListener: stream bytes arrive
    MacListener->>RustFFI: cmux_iroh_connection_recv then echo via send
    RustFFI-->>SwiftHarness: echoed bytes
    SwiftHarness->>RustFFI: cmux_iroh_connection_close finish()+stopped(5s)+Connection::close
    MacListener->>RustFFI: recv returns 0 (FIN) then close
Loading

Reviews (1): Last reviewed commit: "spike: drain finished stream before conn..." | Re-trigger Greptile

Comment on lines +363 to +384
/// Closes the connection and frees its handle.
///
/// Graceful close: `finish()` only queues the FIN plus any buffered stream
/// data, while `Connection::close` is immediate and abandons buffered data.
/// Closing right after finishing could therefore drop a final frame that
/// `send()` already reported as accepted. `stopped()` resolves once the peer
/// acknowledges receipt of all finished stream data, so wait for it (bounded,
/// so a vanished peer cannot wedge close) before closing the connection.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_connection_close(connection: *mut CmuxIrohConnection) {
if connection.is_null() {
return;
}
let connection = unsafe { Box::from_raw(connection) };
runtime().block_on(async {
let mut send = connection.send.lock().await;
if send.finish().is_ok() {
let _ = tokio::time::timeout(Duration::from_secs(5), send.stopped()).await;
}
drop(send);
connection.connection.close(0u32.into(), b"close");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 stopped() semantics are incorrect for the described purpose

The comment says "stopped() resolves once the peer acknowledges receipt of all finished stream data." This is not accurate. In Quinn (iroh's QUIC layer), SendStream::stopped() resolves when the peer sends a STOP_SENDING frame — it does NOT resolve on QUIC data-acknowledgment or on a clean peer receive close. In the happy path where the remote reads all echoed bytes and closes its recv stream normally (no STOP_SENDING), stopped() will always block for the full 5-second bound before Connection::close fires. Every clean session teardown in production would therefore carry a 5-second close latency.

The spike test passes because the dialer calls Connection::close which sends a CONNECTION_CLOSE frame that cancels the listener's pending stopped() immediately (as an error, which let _ = ... discards). That coordination is specific to the two-sided CLI harness, not a general property of stream teardown. This pattern and comment are explicitly called out in the design doc as the basis for PRs 3/4, making the incorrect framing likely to carry forward.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
plans/feat-ios-iroh/DESIGN.md (1)

1-128: 🧹 Nitpick | 🔵 Trivial

Planning document is expected to be temporary.

Per repository conventions, design documents under plans/ are temporary artifacts that should be removed before merge, with the permanent design documentation maintained in the separate control repository.

Based on learnings: "treat design/planning markdown files under the plans/ directory (for example plans/**/DESIGN.md) as expected temporary artifacts. Do not review or flag them for code/documentation quality during PR review; they should be removed before merge and the equivalent, permanent design documentation is maintained in the separate control repository."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/feat-ios-iroh/DESIGN.md` around lines 1 - 128, The DESIGN.md file in
the plans/feat-ios-iroh directory is a temporary planning artifact and must be
removed prior to merge; delete plans/feat-ios-iroh/DESIGN.md (or extract any
finalized content into the permanent control repository) so the repository no
longer contains plans/**/DESIGN.md artifacts, and ensure no build or CI
references (e.g., Delivery plan, iroh as the default cmux iOS-to-Mac transport)
rely on this file before committing the removal.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@experiments/iroh-swift-ffi-spike/README.md`:
- Around line 63-68: The fenced code block that contains the proof output (the
lines starting with "mac listener endpoint-id:" and including "PROOF: dialed by
EndpointId, 45 byte(s) echoed...") is missing a language specifier; update that
Markdown fenced block to include a language identifier such as "text" (e.g.,
replace ``` with ```text) so the block will render and lint correctly.

In `@experiments/iroh-swift-ffi-spike/rust/src/lib.rs`:
- Around line 73-405: Change all exported FFI entrypoints to be declared as pub
unsafe extern "C" fn and add a /// # Safety doc block for each, covering caller
guarantees: validity and lifetime of pointer arguments for the duration of the
call, nullability expectations (which of err_buf, endpoint pointers, buf, bytes,
endpoint_id, relay_url, direct_addrs may be null or are checked), buffer
capacity/ownership (err_buf writable for err_cap bytes, buf writable for cap
bytes, bytes readable for len bytes), and that returned heap handles/strings
must be freed only with the matching cmux_iroh_connection_close /
cmux_iroh_endpoint_close / cmux_iroh_string_free; apply these changes to the
functions cmux_iroh_endpoint_bind, cmux_iroh_endpoint_id,
cmux_iroh_endpoint_route_json, cmux_iroh_endpoint_online,
cmux_iroh_endpoint_accept, cmux_iroh_endpoint_connect,
cmux_iroh_connection_recv, cmux_iroh_connection_send,
cmux_iroh_connection_close, cmux_iroh_endpoint_close, and cmux_iroh_string_free
so all pointer dereferences (c_to_str, from_raw_parts, Box::from_raw,
CString::from_raw, set_error) are explicitly marked unsafe to callers.

---

Outside diff comments:
In `@plans/feat-ios-iroh/DESIGN.md`:
- Around line 1-128: The DESIGN.md file in the plans/feat-ios-iroh directory is
a temporary planning artifact and must be removed prior to merge; delete
plans/feat-ios-iroh/DESIGN.md (or extract any finalized content into the
permanent control repository) so the repository no longer contains
plans/**/DESIGN.md artifacts, and ensure no build or CI references (e.g.,
Delivery plan, iroh as the default cmux iOS-to-Mac transport) rely on this file
before committing the removal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f91fd39-2738-4675-9767-5e92a2d5a29f

📥 Commits

Reviewing files that changed from the base of the PR and between 72812f3 and 8472a72.

⛔ Files ignored due to path filters (1)
  • experiments/iroh-swift-ffi-spike/rust/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • experiments/iroh-swift-ffi-spike/.gitignore
  • experiments/iroh-swift-ffi-spike/README.md
  • experiments/iroh-swift-ffi-spike/build.sh
  • experiments/iroh-swift-ffi-spike/include/cmux_iroh_ffi.h
  • experiments/iroh-swift-ffi-spike/rust/Cargo.toml
  • experiments/iroh-swift-ffi-spike/rust/src/lib.rs
  • experiments/iroh-swift-ffi-spike/swift/main.swift
  • plans/feat-ios-iroh/DESIGN.md

Comment on lines +63 to +68
```
mac listener endpoint-id: 8b5505d8915e8389a3bcf1bd2ff1c7ec5f2184da8cf48fd72c2334757ec63c0e
PROOF: dialed by EndpointId, 45 byte(s) echoed in 1.03s connect
echoed 45 byte(s); peer closed stream
ios-sim dial rc=0, mac listener rc=0
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add language specifier to fenced code block.

The fenced code block showing proof output should specify a language identifier (e.g., text or console) for proper rendering and linting compliance.

📝 Proposed fix
-```
+```text
 mac listener endpoint-id: 8b5505d8915e8389a3bcf1bd2ff1c7ec5f2184da8cf48fd72c2334757ec63c0e
 PROOF: dialed by EndpointId, 45 byte(s) echoed in 1.03s connect
 echoed 45 byte(s); peer closed stream
 ios-sim dial rc=0, mac listener rc=0
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 63-63: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/iroh-swift-ffi-spike/README.md` around lines 63 - 68, The fenced
code block that contains the proof output (the lines starting with "mac listener
endpoint-id:" and including "PROOF: dialed by EndpointId, 45 byte(s) echoed...")
is missing a language specifier; update that Markdown fenced block to include a
language identifier such as "text" (e.g., replace ``` with ```text) so the block
will render and lint correctly.

Source: Linters/SAST tools

Comment on lines +73 to +405
pub extern "C" fn cmux_iroh_endpoint_bind(
enable_relay: bool,
accept_connections: bool,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohEndpoint {
let result = runtime().block_on(async move {
let mut builder = Endpoint::builder(presets::N0)
.secret_key(SecretKey::generate())
.relay_mode(if enable_relay {
RelayMode::Default
} else {
RelayMode::Disabled
});
if accept_connections {
builder = builder.alpns(vec![ALPN.to_vec()]);
}
builder.bind().await
});
match result {
Ok(endpoint) => Box::into_raw(Box::new(CmuxIrohEndpoint { endpoint })),
Err(error) => {
set_error(err_buf, err_cap, &format!("bind failed: {error:#}"));
ptr::null_mut()
}
}
}

/// Returns the endpoint's EndpointId (z-base-32) as a heap string.
/// Free with `cmux_iroh_string_free`.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_id(endpoint: *const CmuxIrohEndpoint) -> *mut c_char {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
return ptr::null_mut();
};
string_to_c(endpoint.endpoint.id().to_string())
}

/// Returns a `CmxAttachRoute`-shaped JSON object for this endpoint
/// (id, direct addrs, relay URL). Free with `cmux_iroh_string_free`.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_route_json(endpoint: *const CmuxIrohEndpoint) -> *mut c_char {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
return ptr::null_mut();
};
let addr = endpoint.endpoint.addr();
let direct_addrs = addr
.ip_addrs()
.map(|addr| addr.to_string())
.collect::<Vec<_>>();
let relay_url = addr.relay_urls().next().map(|url| url.to_string());
// `CmxAttachTicket.preferredRoute` sorts ascending and lower wins, so iroh
// must sit below the Mac's primary Tailscale route (priority 10) to be the
// default; 5 also stays above debugLoopback (0) so DEBUG/simulator runs
// keep preferring the loopback mock host.
let route = serde_json::json!({
"id": "iroh",
"kind": "iroh",
"endpoint": {
"type": "peer",
"id": endpoint.endpoint.id().to_string(),
"direct_addrs": direct_addrs,
"relay_url": relay_url,
},
"priority": 5,
});
string_to_c(route.to_string())
}

/// Waits until the endpoint has a home relay connection (so dial-by-id from
/// elsewhere can reach it). 0 on success, -1 on timeout.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_online(
endpoint: *mut CmuxIrohEndpoint,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> c_int {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return -1;
};
let online = runtime().block_on(async {
tokio::time::timeout(
Duration::from_millis(timeout_ms.max(1)),
endpoint.endpoint.online(),
)
.await
});
match online {
Ok(()) => 0,
Err(_) => {
set_error(err_buf, err_cap, "timed out waiting for relay connection");
-1
}
}
}

/// Accepts one incoming connection and its first bidirectional stream.
/// Blocks up to `timeout_ms`. Returns null on failure/timeout.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_accept(
endpoint: *mut CmuxIrohEndpoint,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohConnection {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return ptr::null_mut();
};
let result = runtime().block_on(async {
tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
let incoming = endpoint
.endpoint
.accept()
.await
.ok_or_else(|| "endpoint closed".to_string())?;
let connection = incoming
.await
.map_err(|error| format!("incoming connection failed: {error:#}"))?;
let (send, recv) = connection
.accept_bi()
.await
.map_err(|error| format!("accept_bi failed: {error:#}"))?;
Ok::<_, String>((connection, send, recv))
})
.await
.map_err(|_| "accept timed out".to_string())?
});
finish_connection(result, err_buf, err_cap)
}

/// Dials `endpoint_id` (optionally with relay URL / direct addr hints) and
/// opens one bidirectional stream. With no hints, n0 discovery resolves the id.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_connect(
endpoint: *mut CmuxIrohEndpoint,
endpoint_id: *const c_char,
relay_url: *const c_char,
direct_addrs: *const *const c_char,
direct_addr_count: usize,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohConnection {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return ptr::null_mut();
};
let Some(id_str) = c_to_str(endpoint_id) else {
set_error(err_buf, err_cap, "null or invalid endpoint id");
return ptr::null_mut();
};
let id = match EndpointId::from_str(id_str) {
Ok(id) => id,
Err(error) => {
set_error(err_buf, err_cap, &format!("invalid endpoint id: {error:#}"));
return ptr::null_mut();
}
};

let mut addrs: Vec<TransportAddr> = Vec::new();
if !direct_addrs.is_null() {
for index in 0..direct_addr_count {
let raw = unsafe { *direct_addrs.add(index) };
let Some(addr_str) = c_to_str(raw) else {
continue;
};
match SocketAddr::from_str(addr_str) {
Ok(addr) => addrs.push(TransportAddr::Ip(addr)),
Err(error) => {
set_error(
err_buf,
err_cap,
&format!("invalid direct addr {addr_str}: {error:#}"),
);
return ptr::null_mut();
}
}
}
}
if let Some(relay_str) = c_to_str(relay_url) {
match RelayUrl::from_str(relay_str) {
Ok(url) => addrs.push(TransportAddr::Relay(url)),
Err(error) => {
set_error(err_buf, err_cap, &format!("invalid relay url: {error:#}"));
return ptr::null_mut();
}
}
}
let addr = if addrs.is_empty() {
EndpointAddr::from(id)
} else {
EndpointAddr::from_parts(id, addrs)
};

let result = runtime().block_on(async {
tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
let connection = endpoint
.endpoint
.connect(addr, ALPN)
.await
.map_err(|error| format!("connect failed: {error:#}"))?;
let (send, recv) = connection
.open_bi()
.await
.map_err(|error| format!("open_bi failed: {error:#}"))?;
Ok::<_, String>((connection, send, recv))
})
.await
.map_err(|_| "connect timed out".to_string())?
});
finish_connection(result, err_buf, err_cap)
}

/// Receives up to `cap` bytes. Returns bytes read (>0), 0 on clean end of
/// stream, or -1 on error.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_connection_recv(
connection: *mut CmuxIrohConnection,
buf: *mut u8,
cap: usize,
err_buf: *mut c_char,
err_cap: usize,
) -> isize {
let Some(connection) = (unsafe { connection.as_ref() }) else {
set_error(err_buf, err_cap, "null connection");
return -1;
};
if buf.is_null() || cap == 0 {
set_error(err_buf, err_cap, "null or empty receive buffer");
return -1;
}
let slice = unsafe { std::slice::from_raw_parts_mut(buf, cap) };
let result = runtime().block_on(async {
let mut recv = connection.recv.lock().await;
recv.read(slice).await
});
match result {
Ok(Some(read)) => read as isize,
Ok(None) => 0,
// A clean peer close (application error code 0) is end-of-stream,
// not an error: QUIC CONNECTION_CLOSE can race the stream FIN.
Err(ReadError::ConnectionLost(ConnectionError::ApplicationClosed(close)))
if u64::from(close.error_code) == 0 =>
{
0
}
Err(error) => {
set_error(err_buf, err_cap, &format!("recv failed: {error:#}"));
-1
}
}
}

/// Sends `len` bytes. Returns 0 on success, -1 on error.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_connection_send(
connection: *mut CmuxIrohConnection,
bytes: *const u8,
len: usize,
err_buf: *mut c_char,
err_cap: usize,
) -> c_int {
let Some(connection) = (unsafe { connection.as_ref() }) else {
set_error(err_buf, err_cap, "null connection");
return -1;
};
if len == 0 {
return 0;
}
if bytes.is_null() {
set_error(err_buf, err_cap, "null send buffer");
return -1;
}
let slice = unsafe { std::slice::from_raw_parts(bytes, len) };
let result = runtime().block_on(async {
let mut send = connection.send.lock().await;
send.write_all(slice).await
});
match result {
Ok(()) => 0,
Err(error) => {
set_error(err_buf, err_cap, &format!("send failed: {error:#}"));
-1
}
}
}

/// Closes the connection and frees its handle.
///
/// Graceful close: `finish()` only queues the FIN plus any buffered stream
/// data, while `Connection::close` is immediate and abandons buffered data.
/// Closing right after finishing could therefore drop a final frame that
/// `send()` already reported as accepted. `stopped()` resolves once the peer
/// acknowledges receipt of all finished stream data, so wait for it (bounded,
/// so a vanished peer cannot wedge close) before closing the connection.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_connection_close(connection: *mut CmuxIrohConnection) {
if connection.is_null() {
return;
}
let connection = unsafe { Box::from_raw(connection) };
runtime().block_on(async {
let mut send = connection.send.lock().await;
if send.finish().is_ok() {
let _ = tokio::time::timeout(Duration::from_secs(5), send.stopped()).await;
}
drop(send);
connection.connection.close(0u32.into(), b"close");
});
}

/// Closes the endpoint and frees its handle.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_close(endpoint: *mut CmuxIrohEndpoint) {
if endpoint.is_null() {
return;
}
let endpoint = unsafe { Box::from_raw(endpoint) };
runtime().block_on(async {
endpoint.endpoint.close().await;
});
}

/// Frees a string returned by this library.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_string_free(string: *mut c_char) {
if string.is_null() {
return;
}
drop(unsafe { CString::from_raw(string) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Current exported signatures =="
rg -nP 'pub\s+extern\s+"C"\s+fn\s+cmux_iroh_' experiments/iroh-swift-ffi-spike/rust/src/lib.rs

echo
echo "== Unsafe exported signatures (expected after fix) =="
rg -nP 'pub\s+unsafe\s+extern\s+"C"\s+fn\s+cmux_iroh_' experiments/iroh-swift-ffi-spike/rust/src/lib.rs || true

Repository: manaflow-ai/cmux

Length of output: 878


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="experiments/iroh-swift-ffi-spike/rust/src/lib.rs"

echo "== Exported cmux_iroh_* signatures =="
rg -n 'pub\s+extern\s+"C"\s+fn\s+cmux_iroh_[a-zA-Z0-9_]*\s*\(' "$FILE"

echo
echo "== Exported cmux_iroh_* that are already marked unsafe extern =="
rg -n 'pub\s+unsafe\s+extern\s+"C"\s+fn\s+cmux_iroh_[a-zA-Z0-9_]*\s*\(' "$FILE" || true

echo
echo "== Helper defs: set_error and string_to_c =="
rg -n 'fn\s+set_error\s*\(|fn\s+string_to_c\s*\(' "$FILE"

echo
echo "== Show set_error + surrounding code (if present) =="
python3 - <<'PY'
import re, itertools, sys, pathlib
file="experiments/iroh-swift-ffi-spike/rust/src/lib.rs"
txt=pathlib.Path(file).read_text()
# crude line extraction
lines=txt.splitlines()
def find_def(name):
    pat=re.compile(rf'\bfn\s+{re.escape(name)}\b')
    for i,l in enumerate(lines):
        if pat.search(l):
            return i
    return None
for name in ["set_error","string_to_c"]:
    idx=find_def(name)
    if idx is None:
        print(f"NOT FOUND: {name}")
        continue
    start=max(0, idx-20); end=min(len(lines), idx+120)
    print(f"\n---- {name} @ lines {start+1}-{end} ----")
    for j in range(start,end):
        print(f"{j+1:5d}:{lines[j]}")
PY

echo
echo "== Show one representative FFI function body using pointer deref/Box-from-raw (if present) =="
python3 - <<'PY'
import pathlib, re
file="experiments/iroh-swift-ffi-spike/rust/src/lib.rs"
lines=pathlib.Path(file).read_text().splitlines()
# find first occurrence of "Box::from_raw" and print surrounding
for i,l in enumerate(lines):
    if "Box::from_raw" in l:
        start=max(0,i-30); end=min(len(lines), i+120)
        print(f"Box::from_raw near line {i+1}:")
        for j in range(start,end):
            print(f"{j+1:5d}:{lines[j]}")
        break
else:
    print("No Box::from_raw found in file (or used under different form).")
PY

echo
echo "== Show all extern "C" exports blocks for cmux_iroh_* (bounded) =="
python3 - <<'PY'
import pathlib, re
file="experiments/iroh-swift-ffi-spike/rust/src/lib.rs"
lines=pathlib.Path(file).read_text().splitlines()
# Collect start lines for pub extern "C" fn cmux_iroh_
starts=[]
pat=re.compile(r'^\s*pub\s+extern\s+"C"\s+fn\s+(cmux_iroh_[a-zA-Z0-9_]+)\s*\(')
for i,l in enumerate(lines):
    m=pat.search(l)
    if m:
        starts.append((i,m.group(1)))
# Print blocks from start to next start (or max lines)
for idx,(start_line,name) in enumerate(starts):
    end_line = starts[idx+1][0] if idx+1<len(starts) else min(len(lines), start_line+160)
    print(f"\n===== {name} block approx lines {start_line+1}-{end_line} =====")
    for j in range(start_line, end_line):
        print(f"{j+1:5d}:{lines[j]}")
PY

Repository: manaflow-ai/cmux

Length of output: 26516


Make all cmux_iroh_* extern C entrypoints unsafe and document FFI invariants

experiments/iroh-swift-ffi-spike/rust/src/lib.rs exports pub extern "C" fn cmux_iroh_* that dereference caller-provided raw pointers in ways that require strict caller guarantees (e.g., ptr::copy_nonoverlapping in set_error, std::slice::from_raw_parts(_mut) in cmux_iroh_connection_send/recv, CStr::from_ptr via c_to_str in cmux_iroh_endpoint_connect, and Box::from_raw/CString::from_raw in the *_close/cmux_iroh_string_free functions). Add pub unsafe extern "C" fn for all exported cmux_iroh_* and include /// # Safety docs specifying: pointer validity for the duration of the call; nullability expectations (some pointers are explicitly checked for null and treated as errors/early returns); buffer capacity/ownership (err_buf writable for err_cap, buf writable for cap, bytes readable for len); and that returned handles/strings must be freed only via the corresponding *_close / cmux_iroh_string_free.

Suggested direction
-#[unsafe(no_mangle)]
-pub extern "C" fn cmux_iroh_endpoint_id(endpoint: *const CmuxIrohEndpoint) -> *mut c_char {
+#[unsafe(no_mangle)]
+/// # Safety
+/// `endpoint` must be a valid, non-dangling pointer returned by `cmux_iroh_endpoint_bind`
+/// (or null, which results in a null return). It must remain valid for the duration of the call.
+pub unsafe extern "C" fn cmux_iroh_endpoint_id(endpoint: *const CmuxIrohEndpoint) -> *mut c_char {

Apply to:
cmux_iroh_endpoint_bind, cmux_iroh_endpoint_id, cmux_iroh_endpoint_route_json, cmux_iroh_endpoint_online, cmux_iroh_endpoint_accept, cmux_iroh_endpoint_connect, cmux_iroh_connection_recv, cmux_iroh_connection_send, cmux_iroh_connection_close, cmux_iroh_endpoint_close, cmux_iroh_string_free.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub extern "C" fn cmux_iroh_endpoint_bind(
enable_relay: bool,
accept_connections: bool,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohEndpoint {
let result = runtime().block_on(async move {
let mut builder = Endpoint::builder(presets::N0)
.secret_key(SecretKey::generate())
.relay_mode(if enable_relay {
RelayMode::Default
} else {
RelayMode::Disabled
});
if accept_connections {
builder = builder.alpns(vec![ALPN.to_vec()]);
}
builder.bind().await
});
match result {
Ok(endpoint) => Box::into_raw(Box::new(CmuxIrohEndpoint { endpoint })),
Err(error) => {
set_error(err_buf, err_cap, &format!("bind failed: {error:#}"));
ptr::null_mut()
}
}
}
/// Returns the endpoint's EndpointId (z-base-32) as a heap string.
/// Free with `cmux_iroh_string_free`.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_id(endpoint: *const CmuxIrohEndpoint) -> *mut c_char {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
return ptr::null_mut();
};
string_to_c(endpoint.endpoint.id().to_string())
}
/// Returns a `CmxAttachRoute`-shaped JSON object for this endpoint
/// (id, direct addrs, relay URL). Free with `cmux_iroh_string_free`.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_route_json(endpoint: *const CmuxIrohEndpoint) -> *mut c_char {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
return ptr::null_mut();
};
let addr = endpoint.endpoint.addr();
let direct_addrs = addr
.ip_addrs()
.map(|addr| addr.to_string())
.collect::<Vec<_>>();
let relay_url = addr.relay_urls().next().map(|url| url.to_string());
// `CmxAttachTicket.preferredRoute` sorts ascending and lower wins, so iroh
// must sit below the Mac's primary Tailscale route (priority 10) to be the
// default; 5 also stays above debugLoopback (0) so DEBUG/simulator runs
// keep preferring the loopback mock host.
let route = serde_json::json!({
"id": "iroh",
"kind": "iroh",
"endpoint": {
"type": "peer",
"id": endpoint.endpoint.id().to_string(),
"direct_addrs": direct_addrs,
"relay_url": relay_url,
},
"priority": 5,
});
string_to_c(route.to_string())
}
/// Waits until the endpoint has a home relay connection (so dial-by-id from
/// elsewhere can reach it). 0 on success, -1 on timeout.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_online(
endpoint: *mut CmuxIrohEndpoint,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> c_int {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return -1;
};
let online = runtime().block_on(async {
tokio::time::timeout(
Duration::from_millis(timeout_ms.max(1)),
endpoint.endpoint.online(),
)
.await
});
match online {
Ok(()) => 0,
Err(_) => {
set_error(err_buf, err_cap, "timed out waiting for relay connection");
-1
}
}
}
/// Accepts one incoming connection and its first bidirectional stream.
/// Blocks up to `timeout_ms`. Returns null on failure/timeout.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_accept(
endpoint: *mut CmuxIrohEndpoint,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohConnection {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return ptr::null_mut();
};
let result = runtime().block_on(async {
tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
let incoming = endpoint
.endpoint
.accept()
.await
.ok_or_else(|| "endpoint closed".to_string())?;
let connection = incoming
.await
.map_err(|error| format!("incoming connection failed: {error:#}"))?;
let (send, recv) = connection
.accept_bi()
.await
.map_err(|error| format!("accept_bi failed: {error:#}"))?;
Ok::<_, String>((connection, send, recv))
})
.await
.map_err(|_| "accept timed out".to_string())?
});
finish_connection(result, err_buf, err_cap)
}
/// Dials `endpoint_id` (optionally with relay URL / direct addr hints) and
/// opens one bidirectional stream. With no hints, n0 discovery resolves the id.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_connect(
endpoint: *mut CmuxIrohEndpoint,
endpoint_id: *const c_char,
relay_url: *const c_char,
direct_addrs: *const *const c_char,
direct_addr_count: usize,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohConnection {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return ptr::null_mut();
};
let Some(id_str) = c_to_str(endpoint_id) else {
set_error(err_buf, err_cap, "null or invalid endpoint id");
return ptr::null_mut();
};
let id = match EndpointId::from_str(id_str) {
Ok(id) => id,
Err(error) => {
set_error(err_buf, err_cap, &format!("invalid endpoint id: {error:#}"));
return ptr::null_mut();
}
};
let mut addrs: Vec<TransportAddr> = Vec::new();
if !direct_addrs.is_null() {
for index in 0..direct_addr_count {
let raw = unsafe { *direct_addrs.add(index) };
let Some(addr_str) = c_to_str(raw) else {
continue;
};
match SocketAddr::from_str(addr_str) {
Ok(addr) => addrs.push(TransportAddr::Ip(addr)),
Err(error) => {
set_error(
err_buf,
err_cap,
&format!("invalid direct addr {addr_str}: {error:#}"),
);
return ptr::null_mut();
}
}
}
}
if let Some(relay_str) = c_to_str(relay_url) {
match RelayUrl::from_str(relay_str) {
Ok(url) => addrs.push(TransportAddr::Relay(url)),
Err(error) => {
set_error(err_buf, err_cap, &format!("invalid relay url: {error:#}"));
return ptr::null_mut();
}
}
}
let addr = if addrs.is_empty() {
EndpointAddr::from(id)
} else {
EndpointAddr::from_parts(id, addrs)
};
let result = runtime().block_on(async {
tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
let connection = endpoint
.endpoint
.connect(addr, ALPN)
.await
.map_err(|error| format!("connect failed: {error:#}"))?;
let (send, recv) = connection
.open_bi()
.await
.map_err(|error| format!("open_bi failed: {error:#}"))?;
Ok::<_, String>((connection, send, recv))
})
.await
.map_err(|_| "connect timed out".to_string())?
});
finish_connection(result, err_buf, err_cap)
}
/// Receives up to `cap` bytes. Returns bytes read (>0), 0 on clean end of
/// stream, or -1 on error.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_connection_recv(
connection: *mut CmuxIrohConnection,
buf: *mut u8,
cap: usize,
err_buf: *mut c_char,
err_cap: usize,
) -> isize {
let Some(connection) = (unsafe { connection.as_ref() }) else {
set_error(err_buf, err_cap, "null connection");
return -1;
};
if buf.is_null() || cap == 0 {
set_error(err_buf, err_cap, "null or empty receive buffer");
return -1;
}
let slice = unsafe { std::slice::from_raw_parts_mut(buf, cap) };
let result = runtime().block_on(async {
let mut recv = connection.recv.lock().await;
recv.read(slice).await
});
match result {
Ok(Some(read)) => read as isize,
Ok(None) => 0,
// A clean peer close (application error code 0) is end-of-stream,
// not an error: QUIC CONNECTION_CLOSE can race the stream FIN.
Err(ReadError::ConnectionLost(ConnectionError::ApplicationClosed(close)))
if u64::from(close.error_code) == 0 =>
{
0
}
Err(error) => {
set_error(err_buf, err_cap, &format!("recv failed: {error:#}"));
-1
}
}
}
/// Sends `len` bytes. Returns 0 on success, -1 on error.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_connection_send(
connection: *mut CmuxIrohConnection,
bytes: *const u8,
len: usize,
err_buf: *mut c_char,
err_cap: usize,
) -> c_int {
let Some(connection) = (unsafe { connection.as_ref() }) else {
set_error(err_buf, err_cap, "null connection");
return -1;
};
if len == 0 {
return 0;
}
if bytes.is_null() {
set_error(err_buf, err_cap, "null send buffer");
return -1;
}
let slice = unsafe { std::slice::from_raw_parts(bytes, len) };
let result = runtime().block_on(async {
let mut send = connection.send.lock().await;
send.write_all(slice).await
});
match result {
Ok(()) => 0,
Err(error) => {
set_error(err_buf, err_cap, &format!("send failed: {error:#}"));
-1
}
}
}
/// Closes the connection and frees its handle.
///
/// Graceful close: `finish()` only queues the FIN plus any buffered stream
/// data, while `Connection::close` is immediate and abandons buffered data.
/// Closing right after finishing could therefore drop a final frame that
/// `send()` already reported as accepted. `stopped()` resolves once the peer
/// acknowledges receipt of all finished stream data, so wait for it (bounded,
/// so a vanished peer cannot wedge close) before closing the connection.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_connection_close(connection: *mut CmuxIrohConnection) {
if connection.is_null() {
return;
}
let connection = unsafe { Box::from_raw(connection) };
runtime().block_on(async {
let mut send = connection.send.lock().await;
if send.finish().is_ok() {
let _ = tokio::time::timeout(Duration::from_secs(5), send.stopped()).await;
}
drop(send);
connection.connection.close(0u32.into(), b"close");
});
}
/// Closes the endpoint and frees its handle.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_endpoint_close(endpoint: *mut CmuxIrohEndpoint) {
if endpoint.is_null() {
return;
}
let endpoint = unsafe { Box::from_raw(endpoint) };
runtime().block_on(async {
endpoint.endpoint.close().await;
});
}
/// Frees a string returned by this library.
#[unsafe(no_mangle)]
pub extern "C" fn cmux_iroh_string_free(string: *mut c_char) {
if string.is_null() {
return;
}
drop(unsafe { CString::from_raw(string) });
pub extern "C" fn cmux_iroh_endpoint_bind(
enable_relay: bool,
accept_connections: bool,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohEndpoint {
let result = runtime().block_on(async move {
let mut builder = Endpoint::builder(presets::N0)
.secret_key(SecretKey::generate())
.relay_mode(if enable_relay {
RelayMode::Default
} else {
RelayMode::Disabled
});
if accept_connections {
builder = builder.alpns(vec![ALPN.to_vec()]);
}
builder.bind().await
});
match result {
Ok(endpoint) => Box::into_raw(Box::new(CmuxIrohEndpoint { endpoint })),
Err(error) => {
set_error(err_buf, err_cap, &format!("bind failed: {error:#}"));
ptr::null_mut()
}
}
}
/// Returns the endpoint's EndpointId (z-base-32) as a heap string.
/// Free with `cmux_iroh_string_free`.
///
/// # Safety
/// `endpoint` must be either a valid, non-dangling pointer returned by `cmux_iroh_endpoint_bind`
/// or null (which results in a null return). If non-null, it must remain valid for the duration of the call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_endpoint_id(endpoint: *const CmuxIrohEndpoint) -> *mut c_char {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
return ptr::null_mut();
};
string_to_c(endpoint.endpoint.id().to_string())
}
/// Returns a `CmxAttachRoute`-shaped JSON object for this endpoint
/// (id, direct addrs, relay URL). Free with `cmux_iroh_string_free`.
///
/// # Safety
/// `endpoint` must be either a valid, non-dangling pointer returned by `cmux_iroh_endpoint_bind`
/// or null (which results in a null return). If non-null, it must remain valid for the duration of the call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_endpoint_route_json(endpoint: *const CmuxIrohEndpoint) -> *mut c_char {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
return ptr::null_mut();
};
let addr = endpoint.endpoint.addr();
let direct_addrs = addr
.ip_addrs()
.map(|addr| addr.to_string())
.collect::<Vec<_>>();
let relay_url = addr.relay_urls().next().map(|url| url.to_string());
// `CmxAttachTicket.preferredRoute` sorts ascending and lower wins, so iroh
// must sit below the Mac's primary Tailscale route (priority 10) to be the
// default; 5 also stays above debugLoopback (0) so DEBUG/simulator runs
// keep preferring the loopback mock host.
let route = serde_json::json!({
"id": "iroh",
"kind": "iroh",
"endpoint": {
"type": "peer",
"id": endpoint.endpoint.id().to_string(),
"direct_addrs": direct_addrs,
"relay_url": relay_url,
},
"priority": 5,
});
string_to_c(route.to_string())
}
/// Waits until the endpoint has a home relay connection (so dial-by-id from
/// elsewhere can reach it). 0 on success, -1 on timeout.
///
/// # Safety
/// `endpoint` must be a valid, non-dangling pointer returned by `cmux_iroh_endpoint_bind`.
/// `err_buf` must be a valid, writable buffer of at least `err_cap` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_endpoint_online(
endpoint: *mut CmuxIrohEndpoint,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> c_int {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return -1;
};
let online = runtime().block_on(async {
tokio::time::timeout(
Duration::from_millis(timeout_ms.max(1)),
endpoint.endpoint.online(),
)
.await
});
match online {
Ok(()) => 0,
Err(_) => {
set_error(err_buf, err_cap, "timed out waiting for relay connection");
-1
}
}
}
/// Accepts one incoming connection and its first bidirectional stream.
/// Blocks up to `timeout_ms`. Returns null on failure/timeout.
///
/// # Safety
/// `endpoint` must be a valid, non-dangling pointer returned by `cmux_iroh_endpoint_bind`.
/// `err_buf` must be a valid, writable buffer of at least `err_cap` bytes.
/// The returned pointer must be freed only via `cmux_iroh_connection_close`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_endpoint_accept(
endpoint: *mut CmuxIrohEndpoint,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohConnection {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return ptr::null_mut();
};
let result = runtime().block_on(async {
tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
let incoming = endpoint
.endpoint
.accept()
.await
.ok_or_else(|| "endpoint closed".to_string())?;
let connection = incoming
.await
.map_err(|error| format!("incoming connection failed: {error:#}"))?;
let (send, recv) = connection
.accept_bi()
.await
.map_err(|error| format!("accept_bi failed: {error:#}"))?;
Ok::<_, String>((connection, send, recv))
})
.await
.map_err(|_| "accept timed out".to_string())?
});
finish_connection(result, err_buf, err_cap)
}
/// Dials `endpoint_id` (optionally with relay URL / direct addr hints) and
/// opens one bidirectional stream. With no hints, n0 discovery resolves the id.
///
/// # Safety
/// `endpoint` must be a valid, non-dangling pointer returned by `cmux_iroh_endpoint_bind`.
/// `endpoint_id` must be a valid, null-terminated UTF-8 C string.
/// `relay_url` must be either null or a valid, null-terminated UTF-8 C string.
/// `direct_addrs` must be either null or point to a valid array of `direct_addr_count` C string pointers, each null-terminated and UTF-8.
/// `err_buf` must be a valid, writable buffer of at least `err_cap` bytes.
/// The returned pointer must be freed only via `cmux_iroh_connection_close`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_endpoint_connect(
endpoint: *mut CmuxIrohEndpoint,
endpoint_id: *const c_char,
relay_url: *const c_char,
direct_addrs: *const *const c_char,
direct_addr_count: usize,
timeout_ms: u64,
err_buf: *mut c_char,
err_cap: usize,
) -> *mut CmuxIrohConnection {
let Some(endpoint) = (unsafe { endpoint.as_ref() }) else {
set_error(err_buf, err_cap, "null endpoint");
return ptr::null_mut();
};
let Some(id_str) = c_to_str(endpoint_id) else {
set_error(err_buf, err_cap, "null or invalid endpoint id");
return ptr::null_mut();
};
let id = match EndpointId::from_str(id_str) {
Ok(id) => id,
Err(error) => {
set_error(err_buf, err_cap, &format!("invalid endpoint id: {error:#}"));
return ptr::null_mut();
}
};
let mut addrs: Vec<TransportAddr> = Vec::new();
if !direct_addrs.is_null() {
for index in 0..direct_addr_count {
let raw = unsafe { *direct_addrs.add(index) };
let Some(addr_str) = c_to_str(raw) else {
continue;
};
match SocketAddr::from_str(addr_str) {
Ok(addr) => addrs.push(TransportAddr::Ip(addr)),
Err(error) => {
set_error(
err_buf,
err_cap,
&format!("invalid direct addr {addr_str}: {error:#}"),
);
return ptr::null_mut();
}
}
}
}
if let Some(relay_str) = c_to_str(relay_url) {
match RelayUrl::from_str(relay_str) {
Ok(url) => addrs.push(TransportAddr::Relay(url)),
Err(error) => {
set_error(err_buf, err_cap, &format!("invalid relay url: {error:#}"));
return ptr::null_mut();
}
}
}
let addr = if addrs.is_empty() {
EndpointAddr::from(id)
} else {
EndpointAddr::from_parts(id, addrs)
};
let result = runtime().block_on(async {
tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
let connection = endpoint
.endpoint
.connect(addr, ALPN)
.await
.map_err(|error| format!("connect failed: {error:#}"))?;
let (send, recv) = connection
.open_bi()
.await
.map_err(|error| format!("open_bi failed: {error:#}"))?;
Ok::<_, String>((connection, send, recv))
})
.await
.map_err(|_| "connect timed out".to_string())?
});
finish_connection(result, err_buf, err_cap)
}
/// Receives up to `cap` bytes. Returns bytes read (>0), 0 on clean end of
/// stream, or -1 on error.
///
/// # Safety
/// `connection` must be a valid, non-dangling pointer returned by `cmux_iroh_endpoint_accept` or `cmux_iroh_endpoint_connect`.
/// `buf` must be a valid, writable buffer of at least `cap` bytes.
/// `err_buf` must be a valid, writable buffer of at least `err_cap` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_connection_recv(
connection: *mut CmuxIrohConnection,
buf: *mut u8,
cap: usize,
err_buf: *mut c_char,
err_cap: usize,
) -> isize {
let Some(connection) = (unsafe { connection.as_ref() }) else {
set_error(err_buf, err_cap, "null connection");
return -1;
};
if buf.is_null() || cap == 0 {
set_error(err_buf, err_cap, "null or empty receive buffer");
return -1;
}
let slice = unsafe { std::slice::from_raw_parts_mut(buf, cap) };
let result = runtime().block_on(async {
let mut recv = connection.recv.lock().await;
recv.read(slice).await
});
match result {
Ok(Some(read)) => read as isize,
Ok(None) => 0,
// A clean peer close (application error code 0) is end-of-stream,
// not an error: QUIC CONNECTION_CLOSE can race the stream FIN.
Err(ReadError::ConnectionLost(ConnectionError::ApplicationClosed(close)))
if u64::from(close.error_code) == 0 =>
{
0
}
Err(error) => {
set_error(err_buf, err_cap, &format!("recv failed: {error:#}"));
-1
}
}
}
/// Sends `len` bytes. Returns 0 on success, -1 on error.
///
/// # Safety
/// `connection` must be a valid, non-dangling pointer returned by `cmux_iroh_endpoint_accept` or `cmux_iroh_endpoint_connect`.
/// `bytes` must be a valid, readable buffer of at least `len` bytes (or null if `len` is 0).
/// `err_buf` must be a valid, writable buffer of at least `err_cap` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_connection_send(
connection: *mut CmuxIrohConnection,
bytes: *const u8,
len: usize,
err_buf: *mut c_char,
err_cap: usize,
) -> c_int {
let Some(connection) = (unsafe { connection.as_ref() }) else {
set_error(err_buf, err_cap, "null connection");
return -1;
};
if len == 0 {
return 0;
}
if bytes.is_null() {
set_error(err_buf, err_cap, "null send buffer");
return -1;
}
let slice = unsafe { std::slice::from_raw_parts(bytes, len) };
let result = runtime().block_on(async {
let mut send = connection.send.lock().await;
send.write_all(slice).await
});
match result {
Ok(()) => 0,
Err(error) => {
set_error(err_buf, err_cap, &format!("send failed: {error:#}"));
-1
}
}
}
/// Closes the connection and frees its handle.
///
/// Graceful close: `finish()` only queues the FIN plus any buffered stream
/// data, while `Connection::close` is immediate and abandons buffered data.
/// Closing right after finishing could therefore drop a final frame that
/// `send()` already reported as accepted. `stopped()` resolves once the peer
/// acknowledges receipt of all finished stream data, so wait for it (bounded,
/// so a vanished peer cannot wedge close) before closing the connection.
///
/// # Safety
/// `connection` must be either a valid, non-dangling pointer returned by `cmux_iroh_endpoint_accept` or `cmux_iroh_endpoint_connect`,
/// or null (which is a no-op). After this call, the pointer must not be used.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_connection_close(connection: *mut CmuxIrohConnection) {
if connection.is_null() {
return;
}
let connection = unsafe { Box::from_raw(connection) };
runtime().block_on(async {
let mut send = connection.send.lock().await;
if send.finish().is_ok() {
let _ = tokio::time::timeout(Duration::from_secs(5), send.stopped()).await;
}
drop(send);
connection.connection.close(0u32.into(), b"close");
});
}
/// Closes the endpoint and frees its handle.
///
/// # Safety
/// `endpoint` must be either a valid, non-dangling pointer returned by `cmux_iroh_endpoint_bind`,
/// or null (which is a no-op). After this call, the pointer must not be used.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_endpoint_close(endpoint: *mut CmuxIrohEndpoint) {
if endpoint.is_null() {
return;
}
let endpoint = unsafe { Box::from_raw(endpoint) };
runtime().block_on(async {
endpoint.endpoint.close().await;
});
}
/// Frees a string returned by this library.
///
/// # Safety
/// `string` must be either a valid, non-dangling pointer returned by `cmux_iroh_endpoint_id`, `cmux_iroh_endpoint_route_json`,
/// or null (which is a no-op). The pointer must not be used after this call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cmux_iroh_string_free(string: *mut c_char) {
if string.is_null() {
return;
}
drop(unsafe { CString::from_raw(string) });
}
🧰 Tools
🪛 Clippy (1.96.0)

[warning] 142-142: unsafe function's docs are missing a # Safety section

(warning)


[warning] 186-186: unsafe function's docs are missing a # Safety section

(warning)


[warning] 193-193: unsafe function's docs are missing a # Safety section

(warning)


[warning] 216-216: unsafe function's docs are missing a # Safety section

(warning)


[warning] 240-240: this function has too many arguments (9/7)

(warning)


[warning] 282-282: unneeded late initialization

(warning)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/iroh-swift-ffi-spike/rust/src/lib.rs` around lines 73 - 405,
Change all exported FFI entrypoints to be declared as pub unsafe extern "C" fn
and add a /// # Safety doc block for each, covering caller guarantees: validity
and lifetime of pointer arguments for the duration of the call, nullability
expectations (which of err_buf, endpoint pointers, buf, bytes, endpoint_id,
relay_url, direct_addrs may be null or are checked), buffer capacity/ownership
(err_buf writable for err_cap bytes, buf writable for cap bytes, bytes readable
for len bytes), and that returned heap handles/strings must be freed only with
the matching cmux_iroh_connection_close / cmux_iroh_endpoint_close /
cmux_iroh_string_free; apply these changes to the functions
cmux_iroh_endpoint_bind, cmux_iroh_endpoint_id, cmux_iroh_endpoint_route_json,
cmux_iroh_endpoint_online, cmux_iroh_endpoint_accept,
cmux_iroh_endpoint_connect, cmux_iroh_connection_recv,
cmux_iroh_connection_send, cmux_iroh_connection_close, cmux_iroh_endpoint_close,
and cmux_iroh_string_free so all pointer dereferences (c_to_str, from_raw_parts,
Box::from_raw, CString::from_raw, set_error) are explicitly marked unsafe to
callers.

@lawrencecchen
lawrencecchen merged commit 6dd725a into main Jun 10, 2026
37 of 38 checks passed
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
…n CI (no behavior change)

Graduates the iroh FFI spike (#5735)
into Native/cmux-iroh: caller-provided 32-byte secret keys (Keychain custody
stays in Swift), stable CmuxIrohErrorKind codes across the FFI, iroh pinned
=1.0.0-rc.1, Rust toolchain pinned via rust-toolchain.toml, clippy-clean with
FFI seam unit tests including a hermetic loopback QUIC roundtrip.

scripts/ensure-cmux-iroh.sh builds the staticlib into a content-hash-cached,
gitignored CmuxIrohFFI.xcframework (macOS arm64+x86_64, iOS device arm64, iOS
sim arm64), mirroring ensure-ghosttykit.sh. The xcframework carries no
headers; Packages/CmuxIrohFFI wraps it in a SwiftPM package that owns the
hand-maintained C header (avoids the BUILT_PRODUCTS_DIR/include
module.modulemap collision with GhosttyKit). The macOS app links the package
product; the iOS app links it via CmuxMobileTransport. No code references the
module yet, so app behavior and size are unchanged.

CI: every app-building workflow provisions the xcframework (cache + ensure
step); test-ios/ios-testflight gain a Rust install; new cmux-iroh-crate job
gates fmt/clippy/tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 8472a725 Deployed Jun 10, 2026 by vercel[bot]
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