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)
}
}