<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Massimo Yang]]></title><description><![CDATA[Massimo Yang]]></description><link>https://massimoyang.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 11:17:05 GMT</lastBuildDate><atom:link href="https://massimoyang.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Not FFI: wiring a SwiftUI shell to a Rust core over stdin/stdout]]></title><description><![CDATA[When people hear "Swift app with a Rust core", they usually picture a static library, a C header from cbindgen or a uniffi-generated binding, and a lot of…]]></description><link>https://massimoyang.hashnode.dev/not-ffi-wiring-a-swiftui-shell-to-a-rust-core-over-stdin-stdout</link><guid isPermaLink="true">https://massimoyang.hashnode.dev/not-ffi-wiring-a-swiftui-shell-to-a-rust-core-over-stdin-stdout</guid><category><![CDATA[Swift]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[macOS]]></category><category><![CDATA[architecture]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Massimo Yang]]></dc:creator><pubDate>Sat, 31 Oct 2026 06:56:46 GMT</pubDate><content:encoded><![CDATA[<p>When people hear "Swift app with a Rust core", they usually picture a static library, a C header from cbindgen or a uniffi-generated binding, and a lot of <code>unsafe</code> pointer handoffs. My app doesn't do any of that. The Swift side and the Rust side never share an ABI or a byte of memory. The Rust core is a separate executable inside the <code>.app</code> bundle, the SwiftUI shell launches it as a child process, and they talk JSON-RPC 2.0 over stdin/stdout, one JSON object per line.</p>
<p>Here's how it actually works, with simplified code, and what it costs.</p>
<h2>Why a process and not a library</h2>
<p>The design record lists the alternatives:</p>
<ul>
<li><strong>In-process Swift/Rust FFI.</strong> Cheapest calls, but a Rust panic or memory bug lands in the UI process, async callbacks across the boundary are awkward, and ABI evolution is expensive.</li>
<li><strong>Local HTTP/gRPC.</strong> Mature streaming, but ports or sockets, service discovery, and more attack surface for a desktop app.</li>
<li><strong>XPC.</strong> Strong macOS isolation, but it ties the core's transport to Apple platforms.</li>
<li><strong>A one-way command/response loop.</strong> Simplest, but the core needs to call <em>back</em> into the host (for credentials and user consent) and both sides need concurrency and cancellation, so it would turn into a home-grown RPC anyway.</li>
</ul>
<p>So: two processes, JSON-RPC 2.0, newline-delimited JSON on the pipes. stdout carries protocol only; stderr carries diagnostics only. Both sides negotiate a protocol version and capabilities in an initial handshake, and both enforce a maximum message size (16 MiB by default).</p>
<p>The upside I care about most is that a crash in the core doesn't take the editor down with it, and that the core doesn't depend on Swift's ABI or UI, so it could also run as a child process of another local host, such as a CLI.</p>
<h2>What lives where</h2>
<p>The split is by <em>who has to own the thing</em>, not by performance:</p>
<p><strong>SwiftUI/AppKit shell</strong>: windows, commands, keyboard shortcuts, accessibility; the editor (TextKit 2) and its buffers, undo and save; the file explorer; the terminal (SwiftTerm with real PTYs, which never touches the core); git status and inline diffs in the UI; user-facing file access grants and settings.</p>
<p><strong>Rust core</strong> (a Cargo workspace split into several crates: agent loop, model adapters, protocol, workspace search, language service, session store): the AI Reader's agent loop, prompts and model adapters (OpenAI-style and Anthropic-style APIs), context budgeting, workspace search on top of ripgrep's <code>grep</code> and <code>ignore</code> crates, language servers over stdio LSP via <code>async-lsp</code>, and the session store as JSONL files.</p>
<p>One detail worth calling out: the core reads the workspace directly instead of asking Swift for file contents. Recursive scans and searches would otherwise shovel whole files across the pipe. But the user may have unsaved edits, so the shell publishes a <strong>buffer overlay</strong>: every dirty document is sent to the core, and the core's reads and searches check the overlay before disk. Disk is the base layer, unsaved buffers sit on top.</p>
<h2>Building the core into a Universal binary</h2>
<p>The core is built by a Run Script phase in the Xcode target. If Xcode asks for more than one architecture, the script builds the core once per Rust target and merges the slices with <code>lipo</code> (simplified example):</p>
<pre><code class="language-zsh"># build-core.sh — simplified example, run from an Xcode build phase
slices=()
for arch in ${=ARCHS}; do
  case $arch in
    arm64)  triple=aarch64-apple-darwin ;;
    x86_64) triple=x86_64-apple-darwin ;;
  esac
  cargo build --release --locked --target $triple
  slices+=(target/$triple/release/core)
done
lipo -create $slices -output "$APP_RESOURCES/core"
lipo "$APP_RESOURCES/core" -verify_arch ${=ARCHS}
</code></pre>
<p>The core executable is copied into the app bundle's resources, and when signing is enabled it is codesigned with the same identity as the app. The release packaging script then walks the bundle and fails if any Mach-O file isn't both <code>arm64</code> and <code>x86_64</code>. Nothing is linked into the Swift binary; the "link step" is really just "copy an executable into the bundle and sign it".</p>
<p>At runtime the shell looks up the core executable in its own bundle, with a fallback to the Cargo debug build during development. The release profile uses thin LTO and strips symbols.</p>
<h2>Launching and the handshake</h2>
<p>On the Swift side, the whole connection lives in an <code>actor</code>. Launching is plain <code>Foundation.Process</code> with three pipes (simplified example):</p>
<pre><code class="language-swift">actor CoreConnection {
    private var generation: UInt64 = 0

    func start(executable: URL) throws {
        generation += 1
        let process = Process()
        process.executableURL = executable
        process.standardInput = Pipe()
        process.standardOutput = Pipe()
        process.standardError = Pipe()
        var env = ProcessInfo.processInfo.environment
        env["HOST_PID"] = String(getpid())   // lets the core notice if the app dies
        process.environment = env
        try process.run()
        // then: start the reader, send the handshake
    }
}
</code></pre>
<p>Note the <code>generation</code> counter. Every reader, writer, pending request and termination callback is tagged with the generation of the process it belongs to, so late callbacks from a core that already died or was restarted are ignored instead of corrupting the state of the new one.</p>
<h2>Async across the boundary</h2>
<p>Requests from Swift become <code>async</code> calls. Each request gets an id with a <code>shell:</code> prefix, its continuation is parked in a dictionary, and a timeout task (30 seconds by default) races the response:</p>
<pre><code class="language-swift">func call(_ method: String, _ params: JSON, timeout: Duration = .seconds(30)) async throws -&gt; JSON {
    let id = "shell:\(nextID)"; nextID += 1
    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation { cont in
            waiting[id] = Waiter(cont: cont, generation: generation,
                                 timer: Task { try? await Task.sleep(for: timeout); timedOut(id) })
            send(Request(id: id, method: method, params: params))
        }
    } onCancel: {
        Task { await self.cancel(id) }   // resumes with CancellationError, notifies the core
    }
}
</code></pre>
<p>Cancelling the Swift task resumes the continuation with <code>CancellationError</code> and sends a JSON-RPC cancellation notification to the core; on the Rust side that cancels the <code>CancellationToken</code> of the matching in-flight request.</p>
<p>The core does the mirror image. When it needs something only the app has, such as the credentials for your own model provider right before a model request, it asks the app for them by sending its own request with a <code>core:</code> prefix and awaiting a <code>tokio::sync::oneshot</code>:</p>
<pre><code class="language-rust">async fn ask_host(&amp;self, method: &amp;str, params: Value) -&gt; Result&lt;Value, Error&gt; {
    let id = format!("core:{}", self.next_id.fetch_add(1, Ordering::Relaxed));
    let (tx, rx) = oneshot::channel();
    self.waiting.lock().insert(id.clone(), tx);
    let _cleanup = RemoveOnDrop(&amp;self.waiting, id.clone());
    self.out.send(Message::request(id, method, params))?;
    let reply = tokio::time::timeout(Duration::from_secs(30), rx).await??;
    reply.into_result()
}
</code></pre>
<p>The <code>shell:</code> / <code>core:</code> prefixes are deliberately boring: two peers that both send requests need separate id namespaces, or a response gets matched to the wrong caller.</p>
<p>On the Rust side, the main loop reads frames from stdin with <code>tokio::select!</code>. Quick requests are handled inline; search, language-server and similar requests are spawned onto their own tokio tasks with a cancellation token, so a slow "find references" doesn't block the next keystroke's completion request. The AI Reader's agent run itself reports progress through event notifications instead of one big response.</p>
<h2>Memory ownership, or the lack of it</h2>
<p>Since nothing crosses as a pointer, the ownership story is simply "each side owns its own copy". The price, as the design notes list it, is serialization cost, message size limits and backpressure, which is what the rest of this section handles.</p>
<p>Reading stdout in Swift happens on a dedicated utility <code>DispatchQueue</code> with a non-blocking file descriptor and 64 KiB reads. Only one bounded batch of frames (at most 256) is in flight to the actor at a time, and the reader waits for an acknowledgement before delivering more, so actor scheduling can't build an unbounded queue in memory. Events then get coalesced before they reach the main actor:</p>
<pre><code class="language-swift">func received(_ event: JSON) {
    pendingEvents.append(event)
    if pendingEvents.count &gt;= 256 { flush(); return }
    if flushTask == nil {
        flushTask = Task {
            try? await Task.sleep(for: .milliseconds(16))
            flush()   // hands the batch to the main actor in one go
        }
    }
}
</code></pre>
<p>So the UI gets streamed tokens in batches of at most one frame interval, not one SwiftUI update per token.</p>
<p>The Rust writer has the opposite problem: what if the UI is busy and stdout fills up? The outbound queue distinguishes <strong>transient</strong> frames (streaming progress events) from <strong>reliable</strong> ones (responses, durable events). Under pressure it evicts the oldest transient frames first. If the queue is full of reliable frames, it fails the transport loudly rather than dropping something silently:</p>
<pre><code class="language-rust">fn push(&amp;mut self, frame: Frame) -&gt; Result&lt;(), Fatal&gt; {
    while self.over_budget(&amp;frame) {
        if self.drop_oldest_streaming() {
            continue;                       // a stale progress update is fine to lose
        }
        if frame.is_streaming() {
            return Ok(());                  // skip this update instead
        }
        return Err(Fatal::QueueFullOfReplies); // never drop a reply silently
    }
    self.queue.push_back(frame);
    Ok(())
}
</code></pre>
<p>Dropping a streaming delta is acceptable because the validated final answer is delivered separately; dropping a response is not.</p>
<p>A small Swift-specific detail: the Swift JSON value type keeps signed and unsigned 64-bit integers as separate cases next to <code>Double</code>, so 64-bit integers coming from Rust don't silently round through <code>Double</code>.</p>
<h2>One schema, two languages</h2>
<p>With no shared types at the ABI level, the contract has to live somewhere. It's a JSON Schema. A small Python script compiles it into validation predicates, the version constant and wire DTOs for both languages, and the generated files are checked in. Roughly like this (simplified example):</p>
<pre><code class="language-json">{ "method": "search", "params": { "type": "object",
  "required": ["query"], "properties": { "query": { "type": "string", "minLength": 1 } } } }
</code></pre>
<pre><code class="language-rust">// generated — do not edit
pub struct SearchParams { pub query: String }
pub fn is_valid_search_params(v: &amp;Value) -&gt; bool { /* required + minLength checks */ }
</code></pre>
<pre><code class="language-swift">// generated — do not edit
public struct SearchParams: Codable { public var query: String }
public static func isValidSearchParams(_ v: JSON) -&gt; Bool { /* same rules */ }
</code></pre>
<p>The generator deliberately supports only the schema features the protocol uses and fails on anything else. Shared fixtures (valid and invalid messages) are checked by both implementations. Not every type is generated yet; some hand-written wire types are still being migrated.</p>
<h2>Errors and process lifecycle</h2>
<p>Errors cross as JSON-RPC errors: standard codes like <code>-32602 Invalid params</code> and <code>-32700 Parse error</code>, plus application codes for things like cancellation, a stale document revision, or an invalid workspace grant. Malformed frames are answered with a parse error and the loop continues; the Swift side treats a malformed <em>known</em> event as a protocol failure and fails all pending requests.</p>
<p>Shutdown escalates step by step: a shutdown request, an exit notification, closing stdin, <code>SIGTERM</code>, and finally <code>SIGKILL</code>, each with a short timeout.</p>
<p>The reverse case, where the app dies and leaves the core behind, is handled by the core itself. Remember the host PID passed in the environment at launch? A watchdog thread checks the parent PID every 250 ms:</p>
<pre><code class="language-rust">std::thread::spawn(move || loop {
    std::thread::sleep(Duration::from_millis(250));
    if unsafe { libc::getppid() } == host_pid { continue; }
    shutdown.cancel();                          // let async tasks clean up
    std::thread::sleep(Duration::from_secs(1));
    std::process::exit(0);                      // then leave regardless
});
</code></pre>
<p>That matters because the core holds an <code>flock</code> on the session store's writer lock. An orphaned core would otherwise block the next launch.</p>
<h2>What it costs</h2>
<p>To be fair to FFI, here's what I pay for this design:</p>
<ul>
<li><strong>Serialization everywhere.</strong> Every buffer overlay is a JSON string. Large content needs paging and range reads, not "just send the file".</li>
<li><strong>A bidirectional peer is harder than a command loop.</strong> Id namespaces, timeouts on both sides, generation tagging and backpressure are all extra machinery a command loop wouldn't need.</li>
<li><strong>Two compilers per architecture.</strong> A Universal release runs <code>cargo build</code> twice and a <code>lipo</code> step on top of the Xcode build.</li>
</ul>
<p>In return I get crash isolation, a core I can drive from a test harness with plain JSONL transcripts, and no pointer-level bridging code between Swift and Rust at all.</p>
<h2>The app this runs in</h2>
<p>All of this is the plumbing behind Yenpo, a macOS app for reading and reviewing the code an AI writes while you build a project with it, and keeping up with its reasoning. It needs macOS 14 or later, ships as a Universal binary in 10 UI languages, and has no account and no in-app telemetry. The AI Reader is read-only by default and uses your own model provider. If you're curious: <a href="https://yenpo.app/?utm_source=hashnode&amp;utm_medium=post&amp;utm_campaign=launch">https://yenpo.app/?utm_source=hashnode&amp;utm_medium=post&amp;utm_campaign=launch</a></p>
<p>I'd be glad to hear from anyone who went the FFI route instead, especially how you handle async callbacks and panics across the boundary.</p>
]]></content:encoded></item></channel></rss>