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/ playwright-report/
*.png *.png
!src/**/*.png !src/**/*.png
.claude/settings.local.json
buildServer.json

View file

@ -85,7 +85,14 @@ class BufferWebSocketClient: NSObject {
} }
func connect() { 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 { guard let baseURL else {
connectionError = WebSocketError.invalidURL connectionError = WebSocketError.invalidURL
return return
@ -98,6 +105,11 @@ class BufferWebSocketClient: NSObject {
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
components?.scheme = baseURL.scheme == "https" ? "wss" : "ws" components?.scheme = baseURL.scheme == "https" ? "wss" : "ws"
components?.path = "/buffers" 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 { guard let wsURL = components?.url else {
connectionError = WebSocketError.invalidURL connectionError = WebSocketError.invalidURL
@ -620,10 +632,16 @@ class BufferWebSocketClient: NSObject {
} }
func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) { func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) {
subscriptions[sessionId] = handler Task { @MainActor [weak self] in
guard let self else { return }
Task {
try? await subscribe(to: sessionId) // 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) { func unsubscribe(from sessionId: String) {
subscriptions.removeValue(forKey: sessionId) Task { @MainActor [weak self] in
guard let self else { return }
Task {
try? await sendMessage(["type": "unsubscribe", "sessionId": sessionId]) // 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 reconnectAttempts = 0
startPingTask() startPingTask()
// Re-subscribe to all sessions // Re-subscribe to all sessions that have handlers
Task { Task { @MainActor [weak self] in
for sessionId in subscriptions.keys { guard let self else { return }
try? await subscribe(to: sessionId)
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 let updateInterval: TimeInterval = 1.0
private init() { private init() {
// Ensure WebSocket is connected when manager is created
if !bufferClient.isConnected {
bufferClient.connect()
}
} }
/// Subscribe to live updates for a session. /// Subscribe to live updates for a session.

View file

@ -587,8 +587,6 @@ struct TerminalView: View {
viewModel.sendInput(text) viewModel.sendInput(text)
}, },
onResize: { cols, rows in onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows) viewModel.resize(cols: cols, rows: rows)
}, },
viewModel: viewModel viewModel: viewModel
@ -602,8 +600,6 @@ struct TerminalView: View {
viewModel.sendInput(text) viewModel.sendInput(text)
}, },
onResize: { cols, rows in onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows) viewModel.resize(cols: cols, rows: rows)
}, },
viewModel: viewModel viewModel: viewModel
@ -662,14 +658,18 @@ class TerminalViewModel {
let session: Session let session: Session
let castRecorder: CastRecorder let castRecorder: CastRecorder
var bufferWebSocketClient: BufferWebSocketClient? let bufferWebSocketClient: BufferWebSocketClient
private var connectionStatusTask: Task<Void, Never>? private var connectionStatusTask: Task<Void, Never>?
private var connectionErrorTask: 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 weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator
init(session: Session) { init(session: Session) {
self.session = session self.session = session
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24) self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
self.bufferWebSocketClient = BufferWebSocketClient.shared
setupTerminal() setupTerminal()
} }
@ -689,41 +689,29 @@ class TerminalViewModel {
isConnecting = true isConnecting = true
errorMessage = nil errorMessage = nil
// Create WebSocket client if needed // Subscribe to terminal events first (stores the handler)
if bufferWebSocketClient == nil { bufferWebSocketClient.subscribe(to: session.id) { [weak self] event in
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
Task { @MainActor in Task { @MainActor in
self?.handleWebSocketEvent(event) self?.handleWebSocketEvent(event)
} }
} }
// Connect to WebSocket - it will automatically subscribe to stored sessions
bufferWebSocketClient.connect()
// Monitor connection status // Monitor connection status
connectionStatusTask?.cancel() connectionStatusTask?.cancel()
connectionStatusTask = Task { [weak self] in connectionStatusTask = Task { [weak self] in
guard let client = self?.bufferWebSocketClient else { return } guard let self else { return }
while !Task.isCancelled { while !Task.isCancelled {
let connected = client.isConnected let connected = self.bufferWebSocketClient.isConnected
await MainActor.run { await MainActor.run {
self?.isConnecting = false self.isConnecting = false
self?.isConnected = connected self.isConnected = connected
if !connected { if !connected {
self?.errorMessage = "WebSocket disconnected" self.errorMessage = "WebSocket disconnected"
} else { } else {
self?.errorMessage = nil self.errorMessage = nil
} }
} }
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
@ -733,12 +721,12 @@ class TerminalViewModel {
// Monitor connection errors // Monitor connection errors
connectionErrorTask?.cancel() connectionErrorTask?.cancel()
connectionErrorTask = Task { [weak self] in connectionErrorTask = Task { [weak self] in
guard let client = self?.bufferWebSocketClient else { return } guard let self else { return }
while !Task.isCancelled { while !Task.isCancelled {
if let error = client.connectionError { if let error = self.bufferWebSocketClient.connectionError {
await MainActor.run { await MainActor.run {
self?.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
self?.isConnecting = false self.isConnecting = false
} }
} }
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds 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() { func disconnect() {
connectionStatusTask?.cancel() connectionStatusTask?.cancel()
connectionErrorTask?.cancel() connectionErrorTask?.cancel()
bufferWebSocketClient?.unsubscribe(from: session.id) resizeDebounceTask?.cancel()
bufferWebSocketClient?.disconnect() bufferWebSocketClient.unsubscribe(from: session.id)
bufferWebSocketClient = nil // Note: Don't disconnect the shared client as other views might be using it
isConnected = false isConnected = false
} }
@ -836,12 +798,7 @@ class TerminalViewModel {
stopRecording() stopRecording()
} }
// Load final snapshot for exited session // Session has exited - no need to load additional content
Task { @MainActor in
// Give the server a moment to finalize the snapshot
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
await loadSnapshot()
}
case .bufferUpdate(let snapshot): case .bufferUpdate(let snapshot):
// Update terminal buffer directly // Update terminal buffer directly
@ -894,18 +851,115 @@ class TerminalViewModel {
} }
func resize(cols: Int, rows: Int) { func resize(cols: Int, rows: Int) {
Task { // Guard against invalid dimensions
do { guard cols > 0 && rows > 0 && cols <= 1000 && rows <= 1000 else {
try await SessionService().resizeTerminal(sessionId: session.id, cols: cols, rows: rows) logger.warning("Ignoring invalid resize: \(cols)x\(rows)")
// If resize succeeded, ensure the flag is cleared 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 isResizeBlockedByServer = false
} catch { }
logger.error("Failed to resize terminal: \(error)") } catch {
// Check if the error is specifically about resize being disabled logger.error("Failed initial terminal resize: \(error)")
if case APIError.resizeDisabledByServer = 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 isResizeBlockedByServer = true
} }
} }
// Note: UI dimensions remain as set, representing the actual terminal view size
} }
} }