System Integration

Unix Domain Socket IPC: How the Mac CLI Talks to the Helper Daemon

TermOnMac splits the Mac side into two processes: a long-running helper daemon that owns the PTY sessions, and a short-lived CLI that the user invokes. They communicate over a Unix domain socket using a length-prefixed JSON protocol.

Why Two Processes

The PTY sessions need to keep running even when the user closes their CLI window. A CLI command like termonmac sessions should be able to query the running sessions without disturbing them. The helper daemon owns the long-lived state; the CLI is a thin client.

The Listening Socket

LocalSocketServer creates a Unix domain socket and listens for connections:

// mac_agent/Sources/MacAgentLib/LocalSocketServer.swift
public func start() throws {
    unlink(socketPath)

    listenFD = socket(AF_UNIX, SOCK_STREAM, 0)
    guard listenFD >= 0 else {
        throw LocalSocketError.socketCreationFailed(errno)
    }
    _ = fcntl(listenFD, F_SETFD, FD_CLOEXEC)

    var addr = sockaddr_un()
    addr.sun_family = sa_family_t(AF_UNIX)
    let pathBytes = socketPath.utf8CString
    guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else {
        throw LocalSocketError.pathTooLong
    }
    // ... copy path bytes into addr.sun_path

    bind(listenFD, sockPtr, addrLen)
    chmod(socketPath, 0o600)
    listen(listenFD, 5)
}

Three details:

  • unlink(socketPath) first to remove any stale socket file
  • FD_CLOEXEC so child processes don’t inherit the listening FD
  • chmod 0600 so only the owner can connect — the user’s other processes can connect, other users on the system cannot

Multiple Concurrent Clients

The server tracks all connected clients in a dictionary keyed by FD:

/// All connected clients, keyed by fd.
private var clients: [Int32: ClientState] = [:]
private let clientsLock = NSLock()

private struct ClientState {
    let conn: ClientConnection
    /// The session this client is attached to, or nil for query-only clients.
    var attachedSessionId: String?
}

Two types of clients:

  • Query-only clients (e.g. termonmac sessions): connect, request data, disconnect
  • Attached clients (e.g. termonmac attach): stay connected and receive push events for a specific session

The class doc comment captures the design intent:

/// Supports multiple concurrent clients. Each client can optionally attach to a session,
/// becoming the "attached client" for that session and receiving push events (output,
/// sessionExited, takenOver). Query-only clients (e.g. `termonmac sessions`) connect,
/// request data, and disconnect without affecting attached clients.

Length-Prefixed JSON Framing

The IPC protocol uses 4-byte big-endian length headers followed by JSON payloads:

// mac_agent/Sources/MacAgentLib/HelperProtocol.swift
public enum IPCFraming {
    /// Write a length-prefixed JSON frame to a file descriptor.
    public static func writeFrame<T: Encodable>(_ value: T, to fd: Int32) throws {
        let data = try JSONEncoder().encode(value)
        var length = UInt32(data.count).bigEndian
        let header = Data(bytes: &length, count: 4)

        let combined = header + data
        try combined.withUnsafeBytes { buf in
            var totalWritten = 0
            while totalWritten < buf.count {
                let n = Darwin.write(fd, ptr, remaining)
                if n > 0 {
                    totalWritten += n
                } else if n < 0 {
                    if errno == EINTR { continue }
                    throw IPCError.writeFailed(errno)
                } else {
                    throw IPCError.connectionClosed
                }
            }
        }
    }

The read side reads exactly 4 bytes for the header, parses the length, then reads exactly that many bytes for the payload:

public static func readFrame<T: Decodable>(_ type: T.Type, from fd: Int32) throws -> T? {
    var headerBuf = [UInt8](repeating: 0, count: 4)
    let gotHeader = try readExact(fd: fd, buffer: ptr.baseAddress!, count: 4)
    guard gotHeader else { return nil }

    let length = Int(UInt32(bigEndian: headerBuf.withUnsafeBytes { $0.load(as: UInt32.self) }))
    guard length > 0, length < 10_000_000 else {
        throw IPCError.invalidFrameLength(length)
    }

    var payload = [UInt8](repeating: 0, count: length)
    try readExact(fd: fd, buffer: ptr.baseAddress!, count: length)

    return try JSONDecoder().decode(type, from: Data(payload))
}

The 10MB limit on frame size is a sanity check against malformed length headers — a frame larger than 10MB would either be a bug or an attack.

readExact Helper

private static func readExact(fd: Int32, buffer: UnsafeMutablePointer<UInt8>, count: Int) throws -> Bool {
    var offset = 0
    while offset < count {
        let n = Darwin.read(fd, buffer.advanced(by: offset), count - offset)
        if n > 0 {
            offset += n
        } else if n == 0 {
            if offset == 0 { return false }  // clean EOF
            throw IPCError.connectionClosed
        } else {
            if errno == EINTR || errno == EAGAIN { continue }
            throw IPCError.readFailed(errno)
        }
    }
    return true
}

read() may return fewer bytes than requested. readExact loops until the full count is read, returning false only on a clean EOF at offset zero (signaling the connection was closed normally).

Request and Response Envelopes

Each frame contains an envelope with a request ID:

public struct IPCRequest: Codable {
    public let id: UInt64
    public let request: HelperRequest
}

public struct IPCResponse: Codable {
    public let id: UInt64?
    public let message: HelperMessage
}

The CLI assigns a unique ID to each request and matches responses by ID. Unsolicited events (like ptyOutput or sessionExited) have id: nil — they’re not responses to any specific request.

IPC Protocol Versioning

The protocol has an explicit version number:

/// IPC protocol version. Bump when making breaking changes to HelperRequest/HelperMessage.
public let ipcProtocolVersion: Int = 1

The CLI can query the helper’s version via HelperRequest.version and refuse to operate if the versions don’t match — preventing crashes when an old CLI talks to a new helper or vice versa.