Relay & Durable Objects

Batching WebSocket Messages: How relay_batch Reduces Overhead

When a shell command produces continuous output — a build log, tail -f, or a long ls -la — the Mac CLI would naively send one WebSocket message per PTY output flush. TermOnMac coalesces these into relay_batch messages: up to 20 encrypted payloads per batch, or one batch every 50ms, whichever comes first.

The Batch Parameters

// mac_agent/Sources/MacAgentLib/RelayConnection.swift
private let batchQueue = DispatchQueue(label: "relay.batch")
private var batchBuffer: [String] = []
private var batchTimer: DispatchWorkItem?
private static let batchFlushInterval: TimeInterval = 0.05  // 50ms
private static let batchMaxSize = 20

50 milliseconds flush interval, 20 payloads maximum per batch.

How Batching Works

When the relay connection encrypts and queues an outgoing payload, it goes into the batch buffer instead of being sent immediately:

private func _flushBatch() {
    guard !batchBuffer.isEmpty else { return }
    batchTimer?.cancel()
    batchTimer = nil
    let payloads = batchBuffer
    batchBuffer = []
    if payloads.count == 1 {
        // Single payload: send as plain relay (backward compat with old relay servers)
        sendRaw(["type": "relay", "payload": payloads[0]])
    } else {
        sendRaw(["type": "relay_batch", "payloads": payloads])
    }
}

The flush logic:

  1. If 1 payload: send as relay (single message, backward compatible)
  2. If 2+ payloads: send as relay_batch

A DispatchWorkItem schedules a flush 50ms out. If another payload arrives before the timer fires, it appends to the buffer. If the buffer reaches 20 payloads, it flushes immediately:

func queueEncryptedPayload(_ base64: String) {
    batchQueue.async { [weak self] in
        guard let self else { return }
        self.batchBuffer.append(base64)
        if self.batchBuffer.count >= Self.batchMaxSize {
            self._flushBatch()
        } else if self.batchTimer == nil {
            let work = DispatchWorkItem { [weak self] in self?._flushBatch() }
            self.batchTimer = work
            self.batchQueue.asyncAfter(deadline: .now() + Self.batchFlushInterval, execute: work)
        }
    }
}

The relay_batch Protocol Message

// relay_server/src/types.ts
export interface RelayBatchClientMessage {
  type: "relay_batch";
  payloads: string[];
}

export interface PeerRelayBatchMessage {
  type: "relay_batch";
  payloads: string[];
}

The relay forwards the entire payloads array to the peer in a single relay_batch message. The iOS app decrypts each payload in order.

Why This Matters

The real driver is per-user quota accounting, not WebSocket framing overhead. TermOnMac meters usage at the message level: each forwarded relay message costs 1 credit against the user’s 5-hour rolling window (see Credit quota and the rolling window). A build log emitting 100 PTY flushes per second would burn 100 credits per second — draining a Free tier’s 1,000-credit window in ten seconds of xcodebuild output.

The relay collapses this in handleRelayBatch:

// relay_server/src/room.ts
private handleRelayBatch(ws: WebSocket, msg: { payloads: string[] }): void {
    // Count entire batch as 1 token for usage metering
    const userId = this.userIdBySocket.get(ws);
    if (userId) {
      const current = this.pendingMessages.get(userId) || 0;
      this.pendingMessages.set(userId, current + 1);  // +1, not +msg.payloads.length
    }
    // forward to peer...
}

A batch of 20 payloads is charged as 1 credit, not 20. For sustained high-throughput output (builds, cat large_file, find /), this yields approximately 8-10× credit savings in practice — enough to make xcodebuild practical on the Free tier instead of an instant quota wipe.

For interactive use (single keystrokes, short command responses), most batches contain only 1-2 payloads, and a single-payload batch is sent as a plain relay message for backward compatibility (see the payloads.count == 1 branch in _flushBatch above). The 50ms window is short enough that interactive latency is not meaningfully affected.

Flush Before Disconnect

When the connection closes, the batch buffer is flushed synchronously before the WebSocket closes:

// mac_agent/Sources/MacAgentLib/RelayConnection.swift
public func disconnect() {
    // Flush pending batch before closing
    batchQueue.sync { _flushBatch() }
    // ... cancel tasks, close WebSocket
}

This ensures that any output buffered in the last 50ms window is delivered before the connection drops.