Repository navigation
iroh default mobile transport: design + green Swift FFI spike - #5735
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis 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. Changesiroh Swift FFI Spike
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 20 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (20 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 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. |
There was a problem hiding this comment.
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 SummaryDesign 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
Confidence Score: 4/5Safe 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 experiments/iroh-swift-ffi-spike/rust/src/lib.rs — specifically the Important Files Changed
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "spike: drain finished stream before conn..." | Re-trigger Greptile |
| /// 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"); | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 | 🔵 TrivialPlanning 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 exampleplans/**/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
⛔ Files ignored due to path filters (1)
experiments/iroh-swift-ffi-spike/rust/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (8)
experiments/iroh-swift-ffi-spike/.gitignoreexperiments/iroh-swift-ffi-spike/README.mdexperiments/iroh-swift-ffi-spike/build.shexperiments/iroh-swift-ffi-spike/include/cmux_iroh_ffi.hexperiments/iroh-swift-ffi-spike/rust/Cargo.tomlexperiments/iroh-swift-ffi-spike/rust/src/lib.rsexperiments/iroh-swift-ffi-spike/swift/main.swiftplans/feat-ios-iroh/DESIGN.md
| ``` | ||
| 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 | ||
| ``` |
There was a problem hiding this comment.
🧹 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
| 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) }); |
There was a problem hiding this comment.
🧩 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 || trueRepository: 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]}")
PYRepository: 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.
| 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.
…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>
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 phoneCmxByteTransportlane, theirohroute 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/andrust/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) beforeConnection::close, so a final acceptedsend()cannot be silently dropped.🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith 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 underexperiments/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, MacMobileHostByteConnection, 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), plusbuild.shand 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 onstopped()afterfinish()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
irohQUIC 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.irohQUIC bi-stream dialed by EndpointId. Publish anirohroute (registry jsonb) with priority 5 so it beats Tailscale; QR flow stays first-trust-only.experiments/iroh-swift-ffi-spike/): minimal Rust staticlib overiroh@1.0.0-rc.1exposing 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 connectionclose(); no binaries committed.Written for commit 8472a72. Summary will update on new commits.
Summary by CodeRabbit
New Features
Documentation
Chores