iOS: Fix websocket + terminal resize loop (#224)

* Fix websocket + terminal resize loop

* Fix

* CR
This commit is contained in:
Thomas Ricouard 2025-07-05 09:26:06 +02:00 committed by GitHub
parent db76cd3c25
commit 4fbb404754
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 171 additions and 92 deletions

2
.gitignore vendored
View file

@ -124,3 +124,5 @@ test-results-*.json
playwright-report/
*.png
!src/**/*.png
.claude/settings.local.json
buildServer.json

View file

@ -85,7 +85,14 @@ class BufferWebSocketClient: NSObject {
}
func connect() {
guard !isConnecting else { return }
guard !isConnecting else {
logger.warning("Already connecting, ignoring connect() call")
return
}
guard !isConnected else {
logger.warning("Already connected, ignoring connect() call")
return
}
guard let baseURL else {
connectionError = WebSocketError.invalidURL
return
@ -98,6 +105,11 @@ class BufferWebSocketClient: NSObject {
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
components?.scheme = baseURL.scheme == "https" ? "wss" : "ws"
components?.path = "/buffers"
// Add authentication token as query parameter (not header)
if let token = authenticationService?.getTokenForQuery() {
components?.queryItems = [URLQueryItem(name: "token", value: token)]
}
guard let wsURL = components?.url else {
connectionError = WebSocketError.invalidURL
@ -620,10 +632,16 @@ class BufferWebSocketClient: NSObject {
}
func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) {
subscriptions[sessionId] = handler
Task {
try? await subscribe(to: sessionId)
Task { @MainActor [weak self] in
guard let self else { return }
// Store the handler
self.subscriptions[sessionId] = handler
// Send subscription message immediately if connected
if self.isConnected {
try? await self.sendMessage(["type": "subscribe", "sessionId": sessionId])
}
}
}
@ -632,10 +650,16 @@ class BufferWebSocketClient: NSObject {
}
func unsubscribe(from sessionId: String) {
subscriptions.removeValue(forKey: sessionId)
Task {
try? await sendMessage(["type": "unsubscribe", "sessionId": sessionId])
Task { @MainActor [weak self] in
guard let self else { return }
// Remove the handler
self.subscriptions.removeValue(forKey: sessionId)
// Send unsubscribe message immediately if connected
if self.isConnected {
try? await self.sendMessage(["type": "unsubscribe", "sessionId": sessionId])
}
}
}
@ -731,10 +755,13 @@ extension BufferWebSocketClient: WebSocketDelegate {
reconnectAttempts = 0
startPingTask()
// Re-subscribe to all sessions
Task {
for sessionId in subscriptions.keys {
try? await subscribe(to: sessionId)
// Re-subscribe to all sessions that have handlers
Task { @MainActor [weak self] in
guard let self else { return }
let sessionIds = Array(self.subscriptions.keys)
for sessionId in sessionIds {
try? await self.sendMessage(["type": "subscribe", "sessionId": sessionId])
}
}
}

View file

@ -22,10 +22,6 @@ final class LivePreviewManager {
private let updateInterval: TimeInterval = 1.0
private init() {
// Ensure WebSocket is connected when manager is created
if !bufferClient.isConnected {
bufferClient.connect()
}
}
/// Subscribe to live updates for a session.

View file

@ -587,8 +587,6 @@ struct TerminalView: View {
viewModel.sendInput(text)
},
onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows)
},
viewModel: viewModel
@ -602,8 +600,6 @@ struct TerminalView: View {
viewModel.sendInput(text)
},
onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows)
},
viewModel: viewModel
@ -662,14 +658,18 @@ class TerminalViewModel {
let session: Session
let castRecorder: CastRecorder
var bufferWebSocketClient: BufferWebSocketClient?
let bufferWebSocketClient: BufferWebSocketClient
private var connectionStatusTask: Task<Void, Never>?
private var connectionErrorTask: Task<Void, Never>?
private var resizeDebounceTask: Task<Void, Never>?
private var hasPerformedInitialResize = false
private var isPerformingInitialResize = false
weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator
init(session: Session) {
self.session = session
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
self.bufferWebSocketClient = BufferWebSocketClient.shared
setupTerminal()
}
@ -689,41 +689,29 @@ class TerminalViewModel {
isConnecting = true
errorMessage = nil
// Create WebSocket client if needed
if bufferWebSocketClient == nil {
bufferWebSocketClient = BufferWebSocketClient()
}
// Connect to WebSocket
bufferWebSocketClient?.connect()
// Load initial snapshot after a brief delay to ensure terminal is ready
Task { @MainActor in
// Wait for terminal view to be initialized
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2s
await loadSnapshot()
}
// Subscribe to terminal events
bufferWebSocketClient?.subscribe(to: session.id) { [weak self] event in
// Subscribe to terminal events first (stores the handler)
bufferWebSocketClient.subscribe(to: session.id) { [weak self] event in
Task { @MainActor in
self?.handleWebSocketEvent(event)
}
}
// Connect to WebSocket - it will automatically subscribe to stored sessions
bufferWebSocketClient.connect()
// Monitor connection status
connectionStatusTask?.cancel()
connectionStatusTask = Task { [weak self] in
guard let client = self?.bufferWebSocketClient else { return }
guard let self else { return }
while !Task.isCancelled {
let connected = client.isConnected
let connected = self.bufferWebSocketClient.isConnected
await MainActor.run {
self?.isConnecting = false
self?.isConnected = connected
self.isConnecting = false
self.isConnected = connected
if !connected {
self?.errorMessage = "WebSocket disconnected"
self.errorMessage = "WebSocket disconnected"
} else {
self?.errorMessage = nil
self.errorMessage = nil
}
}
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
@ -733,12 +721,12 @@ class TerminalViewModel {
// Monitor connection errors
connectionErrorTask?.cancel()
connectionErrorTask = Task { [weak self] in
guard let client = self?.bufferWebSocketClient else { return }
guard let self else { return }
while !Task.isCancelled {
if let error = client.connectionError {
if let error = self.bufferWebSocketClient.connectionError {
await MainActor.run {
self?.errorMessage = error.localizedDescription
self?.isConnecting = false
self.errorMessage = error.localizedDescription
self.isConnecting = false
}
}
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
@ -746,39 +734,13 @@ class TerminalViewModel {
}
}
@MainActor
private func loadSnapshot() async {
do {
let snapshot = try await APIClient.shared.getSessionSnapshot(sessionId: session.id)
// Process the snapshot events
if let header = snapshot.header {
// Initialize terminal with dimensions from header
terminalCols = header.width
terminalRows = header.height
logger.debug("Snapshot header: \(header.width)x\(header.height)")
}
// Feed all output events to the terminal
for event in snapshot.events {
if event.type == .output {
// Feed the actual terminal output data
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(event.data)
}
}
}
} catch {
logger.error("Failed to load terminal snapshot: \(error)")
}
}
func disconnect() {
connectionStatusTask?.cancel()
connectionErrorTask?.cancel()
bufferWebSocketClient?.unsubscribe(from: session.id)
bufferWebSocketClient?.disconnect()
bufferWebSocketClient = nil
resizeDebounceTask?.cancel()
bufferWebSocketClient.unsubscribe(from: session.id)
// Note: Don't disconnect the shared client as other views might be using it
isConnected = false
}
@ -836,12 +798,7 @@ class TerminalViewModel {
stopRecording()
}
// Load final snapshot for exited session
Task { @MainActor in
// Give the server a moment to finalize the snapshot
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
await loadSnapshot()
}
// Session has exited - no need to load additional content
case .bufferUpdate(let snapshot):
// Update terminal buffer directly
@ -894,18 +851,115 @@ class TerminalViewModel {
}
func resize(cols: Int, rows: Int) {
Task {
do {
try await SessionService().resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
// If resize succeeded, ensure the flag is cleared
// Guard against invalid dimensions
guard cols > 0 && rows > 0 && cols <= 1000 && rows <= 1000 else {
logger.warning("Ignoring invalid resize: \(cols)x\(rows)")
return
}
// Guard against blocked resize
guard !isResizeBlockedByServer else {
logger.warning("Resize blocked by server, ignoring resize: \(cols)x\(rows)")
return
}
// Handle initial resize with proper synchronization
if !hasPerformedInitialResize && !isPerformingInitialResize {
isPerformingInitialResize = true
// Always update UI dimensions immediately for consistency
terminalCols = cols
terminalRows = rows
// Perform initial resize after a short delay to let layout settle
resizeDebounceTask?.cancel()
resizeDebounceTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds for initial
guard !Task.isCancelled else {
await MainActor.run {
self?.isPerformingInitialResize = false
}
return
}
await self?.performInitialResize(cols: cols, rows: rows)
}
return
}
// For subsequent resizes, compare against current UI dimensions (not server dimensions)
guard cols != terminalCols || rows != terminalRows else {
return
}
// Only allow significant changes for subsequent resizes
let colDiff = abs(cols - terminalCols)
let rowDiff = abs(rows - terminalRows)
// Only resize if there's a significant change (more than 5 cols/rows difference)
guard colDiff > 5 || rowDiff > 5 else {
logger.debug("Ignoring minor resize change: \(cols)x\(rows) (current: \(terminalCols)x\(terminalRows))")
return
}
// Update UI dimensions immediately
terminalCols = cols
terminalRows = rows
resizeDebounceTask?.cancel()
resizeDebounceTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second for subsequent
guard !Task.isCancelled else { return }
await self?.performResize(cols: cols, rows: rows)
}
}
private func performInitialResize(cols: Int, rows: Int) async {
logger.info("Performing initial terminal resize: \(cols)x\(rows)")
do {
try await SessionService().resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
// If resize succeeded, mark initial resize as complete and clear any server blocks
await MainActor.run {
hasPerformedInitialResize = true
isPerformingInitialResize = false
isResizeBlockedByServer = false
} catch {
logger.error("Failed to resize terminal: \(error)")
// Check if the error is specifically about resize being disabled
if case APIError.resizeDisabledByServer = error {
}
} catch {
logger.error("Failed initial terminal resize: \(error)")
// Check if the error is specifically about resize being disabled
if case APIError.resizeDisabledByServer = error {
await MainActor.run {
hasPerformedInitialResize = true // Mark as done even if blocked to prevent retries
isPerformingInitialResize = false
isResizeBlockedByServer = true
}
} else {
// For other errors, allow retry by clearing the in-progress flag but leaving hasPerformedInitialResize false
await MainActor.run {
isPerformingInitialResize = false
}
}
}
}
private func performResize(cols: Int, rows: Int) async {
logger.info("Resizing terminal: \(cols)x\(rows)")
do {
try await SessionService().resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
// If resize succeeded, ensure the flag is cleared
await MainActor.run {
isResizeBlockedByServer = false
}
} catch {
logger.error("Failed to resize terminal: \(error)")
// Check if the error is specifically about resize being disabled
if case APIError.resizeDisabledByServer = error {
await MainActor.run {
isResizeBlockedByServer = true
}
}
// Note: UI dimensions remain as set, representing the actual terminal view size
}
}