Non-Blocking PTY I/O with GCD Dispatch Sources
After forkpty(), the parent process holds the master file descriptor. Reading from this FD requires a strategy: blocking reads would tie up a thread, polling would waste CPU, and select/poll loops are clunky in Swift. TermOnMac uses GCD DispatchSource for non-blocking event-driven reads.
Setting the FD Non-Blocking
Immediately after the fork, the master FD is set to non-blocking mode:
// mac_agent/Sources/MacAgentLib/PTYSession.swift
// Parent process
self.masterFD = masterFD
self.childPID = pid
let flags = fcntl(masterFD, F_GETFL)
_ = fcntl(masterFD, F_SETFL, flags | O_NONBLOCK)
With O_NONBLOCK, a read() call returns immediately with errno = EAGAIN when no data is available, instead of blocking.
DispatchSource Read Event
A DispatchSourceRead watches the FD and fires a handler whenever data becomes available to read:
let source = DispatchSource.makeReadSource(fileDescriptor: masterFD,
queue: .global(qos: .userInteractive))
source.setEventHandler { [weak self] in
guard let self, self.masterFD >= 0 else { return }
var buf = [UInt8](repeating: 0, count: 8192)
let n = read(self.masterFD, &buf, buf.count)
if n > 0 {
self.outputQueue.async {
self.outputBuffer.append(Data(buf[0..<n]))
if self.outputBuffer.count >= 32768 {
self.flushOutput()
} else if self.flushTimer == nil {
let timer = DispatchSource.makeTimerSource(queue: self.outputQueue)
timer.schedule(deadline: .now() + .milliseconds(200))
timer.setEventHandler { [weak self] in
self?.flushOutput()
}
timer.resume()
self.flushTimer = timer
}
}
} else if n == 0 || (n < 0 && errno != EAGAIN && errno != EINTR) {
source.cancel()
}
}
source.setCancelHandler { [weak self] in
self?.stop()
self?.onExit?()
}
source.resume()
self.dispatchSource = source
The dispatch source runs on .global(qos: .userInteractive) — the highest QoS queue. Terminal output is latency-sensitive; it should not be delayed behind background work.
Read Buffer: 8192 Bytes Per Event
Each event handler invocation reads up to 8,192 bytes. If the PTY has more data available (e.g. a program emitting a large block of output), the source fires again immediately after the handler returns.
Output Queue: Serial Serialization
All buffering and flushing happen on outputQueue, a private serial queue:
private let outputQueue = DispatchQueue(label: "pty.output")
The read handler dispatches buffering onto outputQueue:
self.outputQueue.async {
self.outputBuffer.append(...)
}
The flush method runs on outputQueue via the timer or size threshold. Because all buffer access is serialized on outputQueue, there are no data races on outputBuffer.
Write Path
Writing to the PTY (keyboard input from iPhone) uses a blocking loop with EAGAIN handling:
func write(_ data: Data) {
guard masterFD >= 0 else { return }
data.withUnsafeBytes { buf in
var totalWritten = 0
while totalWritten < buf.count {
let n = Darwin.write(masterFD, ptr, remaining)
if n > 0 {
totalWritten += n
} else if n < 0 {
if errno == EAGAIN || errno == EINTR {
usleep(1000) // 1ms backoff
continue
}
break // real error
} else {
break // EOF
}
}
}
}
The write loop retries on EAGAIN (PTY input buffer full) with a 1ms sleep. This is intentional: input delivery should be complete (not partial), and a brief sleep avoids busy-looping if the shell is temporarily not reading stdin.
Suspend and Resume for FD Passing
The PTY master FD can be passed to a local CLI process (for zero-latency direct I/O). Before passing the FD, the dispatch source is suspended to avoid competing reads:
func suspendOutput() {
guard !outputSuspended else { return }
outputQueue.sync {
flushOutput() // flush pending output before suspending
}
dispatchSource?.suspend()
outputSuspended = true
}
func resumeOutput() {
guard outputSuspended else { return }
outputSuspended = false
dispatchSource?.resume()
}
suspendOutput flushes the current buffer synchronously before suspending, ensuring no buffered data is lost when the dispatch source stops reading.