digidem / digidem/comapeo-core-react-native

refactor(ios): use socketpair(2) instead of bound Unix sockets

Open
#242 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Kotlin
Stars
1
Forks
0
Avg merge
8h 24m
Merged PRs (30d)
9

Description

On iOS, Node runs on a Thread inside the host app process (NodeJSService.runNode()). Both IPC channels are nevertheless AF_UNIX sockets that Node binds to filesystem paths in Application Support, which Swift then connects to as a client. Because the bind happens somewhere inside Node's boot, the Swift side has to cope with a socket that does not exist yet, may disappear, and may need reconnecting — and that coping is the bulk of ios/NodeJSIPC.swift (437 LOC).

Since both ends live in the same process, socketpair(2) gives us both endpoints up front and removes the entire discovery problem. Swift creates the pair before starting Node and passes the fd numbers as argv positionals; Node wraps its end with new net.Socket({ fd }) instead of binding a path.

What this removes

  • waitForFile(atPath:timeoutSeconds: 30) and connectWithRetry (50 ms cadence).
  • The .disconnected/.connecting/.connected/.disconnecting/.error state machine, and with it the pendingMessages flush-on-connect buffer — there is no pre-connection window to buffer for.
  • The reconnect-on-foreground path (OnAppEntersForeground calling connect()).
  • The fd-reassignment hazard that dictates the current shutdown-join-close ordering in disconnect(), plus the per-instance DispatchSpecificKey re-entrance guard.
  • On iOS only, the started/ready replay in SimpleRpcServer: both ends exist from t=0, so there is no late-connecting client to replay to. (Replay stays for Android, where the RN module and the FGS both connect to the same control socket.)

The framing protocol, backend/lib/message-port.js, the control frame vocabulary, the lifecycle state machine, and the rootkey handshake are all unchanged. The split is only in how the transport is created, not how it is framed or what flows over it.

Android is untouched: the FGS is a separate process (android:process=":ComapeoCore"), so it needs a bound socket regardless.

The part that needs designing

Ownership has to move. Today the module owns the comapeo.sock client and dies on every JS reload (OnDestroydisconnect()), while the service owns control.sock. A socketpair cannot be re-established after either end closes, so the service has to own both fds for the process lifetime and relay to whichever module instance is currently alive.

That is a real refactor, but it arguably fixes an ownership smell rather than adding one: the socket's lifetime genuinely belongs to the Node thread's lifetime, not to a module instance that comes and goes with Fast Refresh. The module would hold a reference to the service (as it already does for onStateChange, onMessageError, and getState()) and register a message callback in OnCreate, dropping it in OnDestroy.

Worth confirming during implementation:

  • Buffering across the reload gap. With the service owning the fd, frames can arrive with no module attached. Either drop them (matching today's behaviour, where a disconnected module misses traffic) or hold a bounded queue. Today's pendingMessages buffers the outbound direction only, so dropping is the smaller behavioural change.
  • nodejs-mobile's NodeMobileStartNode is once-per-process, so the fds are created once per app launch and never recycled. A Node crash is already unrecoverable without an app restart, so this adds no new failure mode.
  • Whether both channels move together or control.sock goes first. Control is the simpler one (1:1 on iOS, small frames, owned by the service already) and would prove the argv/fd handoff before comapeo.sock takes on the relay question.

Rough shape

Swift, before runNode():

var fds: [Int32] = [0, 0]
socketpair(AF_UNIX, SOCK_STREAM, 0, &fds)
// fds[0] stays in Swift; fds[1] is inherited by the Node thread
args.append(contentsOf: [String(fds[1]), ...])

Backend, replacing controlIpcServer.listen(controlSocketPath):

controlIpcServer.attach(new net.Socket({ fd: Number(controlSocketFd) }))

SimpleRpcServer/ComapeoRpcServer grow an attach(socket) alongside listen(path); the Android path keeps calling listen.

Why bother

NodeJSIPC.swift is some of the subtlest code in the repo, and essentially all of that subtlety is in service of a discovery-and-reconnection problem that only exists because we chose a bound path for an in-process channel. Nothing about the iOS topology requires it.

This is a simplification proposal, not a bug report — worth doing if the iOS socket lifecycle is costing us in maintenance or flakiness, and worth skipping if it is not.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with ios/NodeJSIPC.swift, NodeJSService.runNode(), and the backend IPC server entry points, including backend/lib/message-port.js and the listen/attach paths described in the issue. Trace fd creation, argv handoff, service ownership, and module reload behavior before choosing whether control.sock moves first. Done means iOS uses socketpairs without path discovery or reconnect state, Android remains unchanged, and framing and lifecycle behavior stay intact.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js, swift
Domain
backend, mobile-dev, networking
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.