PTY & Shell

RingBuffer for Terminal Replay: Restoring State After Reconnect

Each PTY session in TermOnMac maintains a RingBuffer that stores recent terminal output. When the iPhone reconnects after a network drop, it receives the buffered output replayed into the terminal emulator, restoring the session state.

Buffer Per Session

PTYManager holds a ManagedSession struct for each active session:

// mac_agent/Sources/MacAgentLib/PTYManager.swift
struct ManagedSession {
    let pty: PTYSession
    let replayBuffer: RingBuffer
    var name: String
    var cols: Int
    var rows: Int
    var cwd: String?
    // ...
}

When a session is created, the PTY’s onOutput callback writes to both the ring buffer and the live delivery callback:

pty.onOutput = { [weak self] data in
    guard let self else { return }
    replay.append(data)           // always buffer
    self.onOutput?(sessionId, data) // deliver to connected peer, if any
}

Buffer-Only Mode on Disconnect

When the peer (iPhone) disconnects, switchToBufferOnly() changes the onOutput callback to write only to the ring buffer:

public func switchToBufferOnly() {
    lock.lock()
    let allSessions = sessions
    lock.unlock()

    for (_, managed) in allSessions {
        managed.pty.setOnOutput { data in
            managed.replayBuffer.append(data)
            // no live delivery — peer is not connected
        }
    }
}

The PTY process continues running. Shell output accumulates in the ring buffer.

When the peer reconnects, switchToLive() restores both paths:

public func switchToLive() {
    for (sid, managed) in allSessions {
        managed.pty.setOnOutput { [weak self] data in
            managed.replayBuffer.append(data)
            self?.onOutput?(sid, data)
        }
    }
}

Incremental Replay

PTYManager supports incremental replay using a monotonically increasing offset:

/// Returns incremental replay data since the given offset.
/// If offset is nil or too old, returns full replay (isFull = true).
public func replayIncremental(sessionId: String, sinceOffset: UInt64?)
    -> (data: Data, currentOffset: UInt64, isFull: Bool) {

    if let offset = sinceOffset {
        return session.replayBuffer.snapshotSince(offset)
    } else {
        let snapshot = session.replayBuffer.snapshot()
        let currentOffset = session.replayBuffer.currentOffset
        return (snapshot, currentOffset, true)
    }
}

The iPhone tracks currentOffset for each attached session. On reconnect, it sends the last known offset and receives only the delta. If the offset is older than the ring buffer’s retained range, isFull = true is returned and the full current buffer is sent.

Thread-Safe onOutput Assignment

setOnOutput in PTYSession dispatches the assignment onto outputQueue — the same serial queue used by the flush loop:

// mac_agent/Sources/MacAgentLib/PTYSession.swift

/// Thread-safe setter for onOutput — serializes with flushOutput on outputQueue.
func setOnOutput(_ handler: ((Data) -> Void)?) {
    outputQueue.async { [weak self] in
        self?.onOutput = handler
    }
}

Direct assignment of onOutput from outside the queue would be a data race: the flush loop reads onOutput on outputQueue, and a concurrent write from another thread would be unsynchronized. Dispatching the assignment onto the same queue serializes it correctly.

Tee Output Path

When the Mac CLI directly attaches to a session (local attach via Unix socket), the PTY master FD is passed to the CLI process. The CLI reads output directly from the FD. To keep the ring buffer populated without duplicating TerminalQueryInterceptor processing, a separate appendTeeOutput path bypasses the normal flush pipeline:

/// Accept tee data from CLI and deliver it through the normal onOutput path.
/// Bypasses the 200ms timer and TerminalQueryInterceptor — the CLI already
/// read this data directly from the PTY fd and displayed it. We only need it
/// for scrollback buffering. Re-intercepting would send duplicate query responses.
func appendTeeOutput(_ data: Data) {
    outputQueue.async { [weak self] in
        guard let self, !data.isEmpty else { return }
        self.onOutput?(data)
    }
}