PTY & Shell

PTY Output Buffering: 32KB or 200ms, Whichever Comes First

TermOnMac does not send PTY output to the relay on every read. It buffers output and flushes in two conditions: when the buffer reaches 32,768 bytes, or when 200 milliseconds have passed since the last read — whichever comes first.

The Buffering Logic

Inside the DispatchSource read handler:

// mac_agent/Sources/MacAgentLib/PTYSession.swift
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
    }
}

Two thresholds:

  1. 32,768 bytes (32KB) — if the buffer fills to this size before the timer fires, flush immediately
  2. 200 milliseconds — if data has been accumulating for 200ms, flush regardless of size

The timer is created only when there is no existing timer (flushTimer == nil). Subsequent reads within the 200ms window append to outputBuffer without creating additional timers.

The Flush Function

private func flushOutput() {
    flushTimer?.cancel()
    flushTimer = nil
    guard !outputBuffer.isEmpty else { return }

    let result = TerminalQueryInterceptor.intercept(outputBuffer)
    outputBuffer = Data()

    // Write local responses to PTY (zero-latency reply to shell queries).
    if !result.responses.isEmpty {
        let fd = self.masterFD
        DispatchQueue.global(qos: .userInteractive).async {
            for resp in result.responses {
                resp.withUnsafeBytes { buf in
                    _ = Darwin.write(fd, buf.baseAddress!, buf.count)
                }
            }
        }
    }

    guard !result.filteredOutput.isEmpty else { return }
    onOutput?(result.filteredOutput)
}

Before calling onOutput, the buffer is passed through TerminalQueryInterceptor. The interceptor:

  • Detects terminal query escape sequences (e.g. CSI ? 6 n cursor position query, CSI c device attributes query)
  • Generates local responses to those queries (writing them back to the PTY master FD)
  • Filters the queries out of the output stream so they don’t appear in the remote terminal

This matters because programs running in the PTY (like vim) query terminal capabilities. If those queries were forwarded to the iPhone’s SwiftTerm and the responses came from SwiftTerm, the round-trip latency (iPhone → relay → Mac → PTY) would add significant lag to terminal startup and cursor positioning. Answering locally eliminates this.

Why 200ms

200ms is a tradeoff between latency and message overhead. For interactive use (typing characters), individual keystrokes produce small amounts of output. Batching them for 200ms means a character you type might take up to 200ms to appear on the iPhone — which is noticeable.

However, for programs that emit continuous output (build logs, cat of a large file), buffering into 32KB chunks is more efficient than sending dozens of small messages per second. The timer ensures that even slow trickles of output are flushed within 200ms.

Terminal input latency (character → echo) is different: the Mac receives the keystroke, writes it to the PTY stdin, the shell processes it and writes to stdout, and that output comes back via this buffer. The 200ms applies to the output buffer only, not the input path.

Why 32KB

8,192 bytes is read per DispatchSource event (the read buffer size). The output buffer flushes at 32,768 bytes — exactly four read events. This means the flush happens after accumulating a meaningful batch without building up unbounded data in memory before sending.

Thread Safety of flushOutput

All calls to flushOutput happen on outputQueue (the serial queue):

  • The size-threshold trigger dispatches to outputQueue
  • The timer fires on outputQueue (timer was created with outputQueue)
  • suspendOutput calls outputQueue.sync { flushOutput() } before suspending

flushTimer and outputBuffer are only accessed from outputQueue, so no locking is needed.