From 4476f41aa687d522224d25406211bcd287701f4e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 03:51:42 +0000 Subject: [PATCH] Reconnect terminal websockets automatically --- .../tmuxandroid/AppEventSocketClient.java | 119 +++++++++---- .../neatstudio/tmuxandroid/MainActivity.java | 163 +++++++++++++++++- .../tmuxandroid/TerminalSocketClient.java | 43 ++++- 3 files changed, 277 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/AppEventSocketClient.java b/app/src/main/java/com/neatstudio/tmuxandroid/AppEventSocketClient.java index 8da6c61..7bfbd05 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/AppEventSocketClient.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/AppEventSocketClient.java @@ -4,6 +4,7 @@ import android.util.Base64; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; +import java.net.InetSocketAddress; import java.net.Socket; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -14,16 +15,22 @@ import java.util.Arrays; import javax.net.ssl.SSLSocketFactory; final class AppEventSocketClient { + private static final long HEARTBEAT_INTERVAL_MS = 15000L; + private static final int SOCKET_CONNECT_TIMEOUT_MS = 10000; + private static final int SOCKET_READ_TIMEOUT_MS = 45000; + interface Listener { void onMessage(String text); void onClosed(); } private final Listener listener; + private final Object writeLock = new Object(); private Socket socket; private BufferedInputStream input; private BufferedOutputStream output; private volatile boolean closed; + private Thread heartbeatThread; AppEventSocketClient(Listener listener) { this.listener = listener; @@ -40,12 +47,11 @@ final class AppEventSocketClient { sendFrame(8, new byte[0]); } catch (Exception ignored) { } - try { - if (socket != null) { - socket.close(); - } - } catch (Exception ignored) { - } + closeSocketQuietly(); + } + + boolean isClosed() { + return closed; } private void run(String baseUrl) { @@ -55,6 +61,7 @@ final class AppEventSocketClient { input = new BufferedInputStream(socket.getInputStream()); output = new BufferedOutputStream(socket.getOutputStream()); handshake(uri); + startHeartbeat(); while (!closed) { Frame frame = readFrame(); if (frame.opcode == 1) { @@ -69,12 +76,7 @@ final class AppEventSocketClient { } finally { closed = true; listener.onClosed(); - try { - if (socket != null) { - socket.close(); - } - } catch (Exception ignored) { - } + closeSocketQuietly(); } } @@ -91,10 +93,38 @@ final class AppEventSocketClient { if (port == -1) { port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; } + Socket raw = new Socket(); + raw.connect(new InetSocketAddress(uri.getHost(), port), SOCKET_CONNECT_TIMEOUT_MS); + Socket connected; if ("wss".equalsIgnoreCase(uri.getScheme())) { - return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port); + connected = SSLSocketFactory.getDefault().createSocket(raw, uri.getHost(), port, true); + } else { + connected = raw; } - return new Socket(uri.getHost(), port); + connected.setKeepAlive(true); + connected.setTcpNoDelay(true); + connected.setSoTimeout(SOCKET_READ_TIMEOUT_MS); + return connected; + } + + private void startHeartbeat() { + heartbeatThread = new Thread(() -> { + while (!closed) { + try { + Thread.sleep(HEARTBEAT_INTERVAL_MS); + if (!closed) { + sendFrame(9, new byte[0]); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } catch (Exception error) { + closeSocketQuietly(); + return; + } + } + }, "app-events-ws-heartbeat"); + heartbeatThread.start(); } private void handshake(URI uri) throws Exception { @@ -181,32 +211,47 @@ final class AppEventSocketClient { } private void sendFrame(int opcode, byte[] payload) throws Exception { - if (output == null) { - return; - } - output.write(0x80 | opcode); - byte[] mask = new byte[4]; - new SecureRandom().nextBytes(mask); - int length = payload.length; - if (length < 126) { - output.write(0x80 | length); - } else if (length <= 0xffff) { - output.write(0x80 | 126); - output.write((length >>> 8) & 0xff); - output.write(length & 0xff); - } else { - output.write(0x80 | 127); - for (int i = 7; i >= 0; i--) { - output.write((length >>> (8 * i)) & 0xff); + synchronized (writeLock) { + if (output == null) { + return; } + output.write(0x80 | opcode); + byte[] mask = new byte[4]; + new SecureRandom().nextBytes(mask); + int length = payload.length; + if (length < 126) { + output.write(0x80 | length); + } else if (length <= 0xffff) { + output.write(0x80 | 126); + output.write((length >>> 8) & 0xff); + output.write(length & 0xff); + } else { + output.write(0x80 | 127); + for (int i = 7; i >= 0; i--) { + output.write((length >>> (8 * i)) & 0xff); + } + } + output.write(mask); + byte[] masked = Arrays.copyOf(payload, payload.length); + for (int i = 0; i < masked.length; i++) { + masked[i] = (byte) (masked[i] ^ mask[i % 4]); + } + output.write(masked); + output.flush(); } - output.write(mask); - byte[] masked = Arrays.copyOf(payload, payload.length); - for (int i = 0; i < masked.length; i++) { - masked[i] = (byte) (masked[i] ^ mask[i % 4]); + } + + private void closeSocketQuietly() { + if (heartbeatThread != null) { + heartbeatThread.interrupt(); + heartbeatThread = null; + } + try { + if (socket != null) { + socket.close(); + } + } catch (Exception ignored) { } - output.write(masked); - output.flush(); } private static final class Frame { diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java index 90c747c..183ebd3 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java @@ -18,6 +18,8 @@ import android.graphics.drawable.StateListDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.provider.Settings; import android.text.TextUtils; import android.text.InputType; @@ -68,6 +70,7 @@ public final class MainActivity extends Activity { private static final int STATUS_SUCCESS = 2; private static final int STATUS_ERROR = 3; private static final long TERMINAL_RENDER_INTERVAL_MS = 80L; + private static final long[] SOCKET_RECONNECT_DELAYS_MS = {1000L, 2000L, 4000L, 8000L, 15000L}; private static final int COLOR_APP_BG = Color.rgb(9, 11, 13); private static final int COLOR_BAR = Color.rgb(15, 18, 21); private static final int COLOR_PANEL = Color.rgb(22, 26, 30); @@ -104,6 +107,7 @@ public final class MainActivity extends Activity { }; private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); private SharedPreferences prefs; private UpdateManager updateManager; private SessionApiClient api; @@ -131,10 +135,18 @@ public final class MainActivity extends Activity { private TerminalScreenBuffer terminalScreen = new TerminalScreenBuffer(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS); private final StringBuilder queuedTerminalInput = new StringBuilder(); private boolean terminalConnected; + private boolean terminalConnecting; private boolean terminalRenderPending; private boolean terminalSelectionEnabled; private boolean terminalFollowOutput = true; private int terminalKeyPage; + private int terminalReconnectAttempt; + private int terminalConnectionGeneration; + private int eventReconnectAttempt; + private int eventConnectionGeneration; + private boolean activityDestroyed; + private Runnable terminalReconnectTask; + private Runnable eventReconnectTask; private long lastTerminalRenderMs; private int terminalCols = DEFAULT_TERMINAL_COLS; private int terminalRows = DEFAULT_TERMINAL_ROWS; @@ -2307,9 +2319,22 @@ public final class MainActivity extends Activity { } private void connectTerminal(String sessionName) { - closeTerminalSocket(); - queuedTerminalInput.setLength(0); + terminalReconnectAttempt = 0; + terminalConnectionGeneration++; + cancelTerminalReconnect(); + closeTerminalSocket(false); + connectTerminalSocket(sessionName, terminalConnectionGeneration); + } + + private void connectTerminalSocket(String sessionName, int generation) { + if (activityDestroyed + || activeSessionName == null + || !sessionName.equals(activeSessionName) + || generation != terminalConnectionGeneration) { + return; + } terminalConnected = false; + terminalConnecting = true; terminalSocketStatus = "terminal connecting"; updateTerminalMeta(); setStatus("Connecting " + sessionName); @@ -2319,7 +2344,12 @@ public final class MainActivity extends Activity { @Override public void onConnected() { runOnUiThread(() -> { + if (!isCurrentTerminalConnection(sessionName, generation)) { + return; + } terminalConnected = true; + terminalConnecting = false; + terminalReconnectAttempt = 0; terminalSocketStatus = "terminal connected"; updateTerminalMeta(); setStatus("Connected " + sessionName); @@ -2330,12 +2360,20 @@ public final class MainActivity extends Activity { @Override public void onOutput(String data) { - runOnUiThread(() -> appendTerminal(data)); + runOnUiThread(() -> { + if (isCurrentTerminalConnection(sessionName, generation)) { + appendTerminal(data); + } + }); } @Override public void onError(String message) { runOnUiThread(() -> { + if (!isCurrentTerminalConnection(sessionName, generation)) { + return; + } + terminalConnecting = false; appendTerminal("\r\n[error] " + message + "\r\n"); terminalSocketStatus = "terminal error"; updateTerminalMeta(); @@ -2346,10 +2384,15 @@ public final class MainActivity extends Activity { @Override public void onClosed() { runOnUiThread(() -> { + if (!isCurrentTerminalConnection(sessionName, generation)) { + return; + } terminalConnected = false; + terminalConnecting = false; terminalSocketStatus = "terminal disconnected"; updateTerminalMeta(); setStatus("Disconnected " + sessionName); + scheduleTerminalReconnect(sessionName, generation); }); } }); @@ -2410,7 +2453,8 @@ public final class MainActivity extends Activity { } private void sendTerminalInput(String data) { - if (activeSessionName == null) { + String sessionName = activeSessionName; + if (sessionName == null) { return; } TerminalSocketClient socket = terminalSocket; @@ -2419,7 +2463,7 @@ public final class MainActivity extends Activity { setStatus("Sent input"); return; } - if (socket != null && !socket.isClosed()) { + if ((socket != null && !socket.isClosed()) || terminalConnecting || terminalReconnectTask != null) { queuedTerminalInput.append(data); setStatus("Queued input until terminal connects"); return; @@ -2427,7 +2471,7 @@ public final class MainActivity extends Activity { executor.execute(() -> { try { for (int i = 0; i < data.length(); i += 200) { - api.sendInput(activeSessionName, data.substring(i, Math.min(i + 200, data.length()))); + api.sendInput(sessionName, data.substring(i, Math.min(i + 200, data.length()))); } runOnUiThread(() -> setStatus("Sent input")); } catch (Exception error) { @@ -2948,9 +2992,53 @@ public final class MainActivity extends Activity { return Math.round(value * getResources().getDisplayMetrics().density); } + private boolean isCurrentTerminalConnection(String sessionName, int generation) { + return !activityDestroyed + && generation == terminalConnectionGeneration + && sessionName.equals(activeSessionName); + } + + private void scheduleTerminalReconnect(String sessionName, int generation) { + if (!isCurrentTerminalConnection(sessionName, generation) || terminalReconnectTask != null) { + return; + } + long delay = reconnectDelay(terminalReconnectAttempt++); + terminalSocketStatus = "retry in " + Math.max(1L, delay / 1000L) + "s"; + updateTerminalMeta(); + setStatus("Terminal reconnecting in " + Math.max(1L, delay / 1000L) + "s"); + terminalReconnectTask = () -> { + terminalReconnectTask = null; + if (isCurrentTerminalConnection(sessionName, generation)) { + connectTerminalSocket(sessionName, generation); + } + }; + mainHandler.postDelayed(terminalReconnectTask, delay); + } + + private void cancelTerminalReconnect() { + if (terminalReconnectTask != null) { + mainHandler.removeCallbacks(terminalReconnectTask); + terminalReconnectTask = null; + } + } + + private long reconnectDelay(int attempt) { + return SOCKET_RECONNECT_DELAYS_MS[Math.min(attempt, SOCKET_RECONNECT_DELAYS_MS.length - 1)]; + } + private void closeTerminalSocket() { + closeTerminalSocket(true); + } + + private void closeTerminalSocket(boolean invalidateConnection) { + if (invalidateConnection) { + terminalConnectionGeneration++; + terminalReconnectAttempt = 0; + cancelTerminalReconnect(); + queuedTerminalInput.setLength(0); + } terminalConnected = false; - queuedTerminalInput.setLength(0); + terminalConnecting = false; terminalRenderPending = false; if (terminalSocket != null) { terminalSocket.close(); @@ -2959,25 +3047,69 @@ public final class MainActivity extends Activity { } private void connectAppEvents() { + eventReconnectAttempt = 0; + eventConnectionGeneration++; + cancelEventReconnect(); if (eventSocket != null) { eventSocket.close(); + eventSocket = null; + } + connectAppEventSocket(api.getBaseUrl(), eventConnectionGeneration); + } + + private void connectAppEventSocket(String baseUrl, int generation) { + if (activityDestroyed || generation != eventConnectionGeneration) { + return; } eventSocket = new AppEventSocketClient(new AppEventSocketClient.Listener() { @Override public void onMessage(String text) { - runOnUiThread(() -> handleAppEvent(text)); + runOnUiThread(() -> { + if (generation == eventConnectionGeneration && !activityDestroyed) { + handleAppEvent(text); + } + }); } @Override public void onClosed() { runOnUiThread(() -> { + if (generation != eventConnectionGeneration || activityDestroyed) { + return; + } terminalEventStatus = "events disconnected"; updateTerminalMeta(); setStatus("Event stream disconnected"); + scheduleEventReconnect(baseUrl, generation); }); } }); - eventSocket.connect(api.getBaseUrl()); + eventSocket.connect(baseUrl); + } + + private void scheduleEventReconnect(String baseUrl, int generation) { + if (activityDestroyed + || generation != eventConnectionGeneration + || eventReconnectTask != null) { + return; + } + long delay = reconnectDelay(eventReconnectAttempt++); + terminalEventStatus = "events retry in " + Math.max(1L, delay / 1000L) + "s"; + updateTerminalMeta(); + eventReconnectTask = () -> { + eventReconnectTask = null; + if (!activityDestroyed && generation == eventConnectionGeneration) { + connectAppEventSocket(baseUrl, generation); + } + }; + mainHandler.postDelayed(eventReconnectTask, delay); + } + + private void cancelEventReconnect() { + if (eventReconnectTask != null) { + mainHandler.removeCallbacks(eventReconnectTask); + eventReconnectTask = null; + } } private void handleAppEvent(String text) { @@ -2985,6 +3117,7 @@ public final class MainActivity extends Activity { JSONObject event = new JSONObject(text); String type = event.optString("type", ""); if ("hello".equals(type)) { + eventReconnectAttempt = 0; terminalEventStatus = "events connected"; updateTerminalMeta(); setStatus("Event stream connected"); @@ -3077,11 +3210,23 @@ public final class MainActivity extends Activity { if (updateManager != null) { updateManager.resumePendingInstall(); } + if (activeSessionName != null + && !terminalConnected + && !terminalConnecting + && terminalReconnectTask == null) { + scheduleTerminalReconnect(activeSessionName, terminalConnectionGeneration); + } + if ((eventSocket == null || eventSocket.isClosed()) && eventReconnectTask == null) { + connectAppEvents(); + } } @Override protected void onDestroy() { + activityDestroyed = true; closeTerminalSocket(); + eventConnectionGeneration++; + cancelEventReconnect(); if (eventSocket != null) { eventSocket.close(); eventSocket = null; diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java index 64e238e..0f9e8cf 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/TerminalSocketClient.java @@ -6,6 +6,7 @@ import org.json.JSONObject; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; +import java.net.InetSocketAddress; import java.net.Socket; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -19,6 +20,10 @@ import java.util.concurrent.RejectedExecutionException; import javax.net.ssl.SSLSocketFactory; final class TerminalSocketClient { + private static final long HEARTBEAT_INTERVAL_MS = 15000L; + private static final int SOCKET_CONNECT_TIMEOUT_MS = 10000; + private static final int SOCKET_READ_TIMEOUT_MS = 45000; + interface Listener { void onConnected(); void onOutput(String data); @@ -35,6 +40,7 @@ final class TerminalSocketClient { private BufferedOutputStream output; private volatile boolean closed; private Thread thread; + private Thread heartbeatThread; TerminalSocketClient(Listener listener) { this.listener = listener; @@ -97,6 +103,7 @@ final class TerminalSocketClient { "rows", rows ); listener.onConnected(); + startHeartbeat(); readLoop(); } catch (Exception error) { if (!closed) { @@ -123,10 +130,38 @@ final class TerminalSocketClient { if (port == -1) { port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; } + Socket raw = new Socket(); + raw.connect(new InetSocketAddress(uri.getHost(), port), SOCKET_CONNECT_TIMEOUT_MS); + Socket connected; if ("wss".equalsIgnoreCase(uri.getScheme())) { - return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port); + connected = SSLSocketFactory.getDefault().createSocket(raw, uri.getHost(), port, true); + } else { + connected = raw; } - return new Socket(uri.getHost(), port); + connected.setKeepAlive(true); + connected.setTcpNoDelay(true); + connected.setSoTimeout(SOCKET_READ_TIMEOUT_MS); + return connected; + } + + private void startHeartbeat() { + heartbeatThread = new Thread(() -> { + while (!closed) { + try { + Thread.sleep(HEARTBEAT_INTERVAL_MS); + if (!closed) { + sendFrame(9, new byte[0]); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } catch (Exception error) { + closeSocketQuietly(); + return; + } + } + }, "terminal-ws-heartbeat"); + heartbeatThread.start(); } private void handshake(URI uri) throws Exception { @@ -279,6 +314,10 @@ final class TerminalSocketClient { } private void closeSocketQuietly() { + if (heartbeatThread != null) { + heartbeatThread.interrupt(); + heartbeatThread = null; + } try { if (socket != null) { socket.close();