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 fileFD_CLOEXECso child processes don’t inherit the listening FDchmod 0600so 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.