Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 38 additions & 15 deletions src/components/terminal/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ export default class TerminalComponent {
this.boundNativeSelectionMenuHandler = null;
this.visibleScrollbarWidth = undefined;
this.lastRequestedServerSize = null;
// Lifecycle flags so exit/disconnect/error don't race into zombie tabs
this.intentionalClose = false;
this.processExited = false;

this.init();
}
Expand Down Expand Up @@ -336,7 +339,7 @@ export default class TerminalComponent {
this.container,
{
tapHoldDuration:
terminalSettings.touchSelectionTapHoldDuration || 600,
terminalSettings.touchSelectionTapHoldDuration || 400,
moveThreshold: terminalSettings.touchSelectionMoveThreshold || 8,
handleSize: terminalSettings.touchSelectionHandleSize || 24,
hapticFeedback:
Expand Down Expand Up @@ -853,19 +856,23 @@ export default class TerminalComponent {
};

websocket.onmessage = (event) => {
// Handle text messages (exit events)
if (typeof event.data === "string") {
try {
const message = JSON.parse(event.data);
if (message.type === "exit") {
this.onProcessExit?.(message.data);
return;
}
} catch (error) {
// Not a JSON message, let attachAddon handle it
// Lifecycle control (AXS exit JSON) is always a text frame.
// Never decode binary frames as exit — ordinary PTY output can
// contain the same bytes and must not close the session.
// AttachAddon still receives frames via addEventListener for I/O.
if (typeof event.data !== "string") return;
// Cordova websocket may flag binary payloads even when decoded as string
if (event.binary === true) return;

try {
const message = JSON.parse(event.data);
if (message?.type === "exit") {
this.processExited = true;
this.onProcessExit?.(message.data);
}
} catch {
// Not a JSON control message — terminal I/O is handled by AttachAddon.
}
// For binary data or non-exit text messages, let attachAddon handle them
};

websocket.onclose = (event) => {
Expand All @@ -881,7 +888,12 @@ export default class TerminalComponent {
return;
}

this.onDisconnect?.();
this.onDisconnect?.({
intentional: this.intentionalClose,
processExited: this.processExited,
code: event?.code,
reason: event?.reason,
});
};

websocket.onerror = (error) => {
Expand All @@ -894,6 +906,9 @@ export default class TerminalComponent {
return;
}

// Ignore teardown noise from intentional close / already-handled exit
if (this.intentionalClose || this.processExited) return;

console.error("WebSocket error:", error);
this.onError?.(error);
};
Expand Down Expand Up @@ -1269,8 +1284,15 @@ export default class TerminalComponent {
* Terminate terminal session
*/
async terminate() {
this.intentionalClose = true;

if (this.websocket) {
this.websocket.close();
try {
this.websocket.close();
} catch {
// Already closed
}
this.websocket = null;
}

if (this.pid && this.serverMode) {
Expand All @@ -1296,6 +1318,7 @@ export default class TerminalComponent {
* Dispose terminal
*/
dispose() {
this.intentionalClose = true;
this.terminate();

// Dispose touch selection
Expand Down Expand Up @@ -1334,7 +1357,7 @@ export default class TerminalComponent {

// Event handlers (can be overridden)
onConnect() {}
onDisconnect() {}
onDisconnect(_info) {}
onError(error) {}
onTitleChange(title) {}
onBell() {}
Expand Down
2 changes: 1 addition & 1 deletion src/components/terminal/terminalDefaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const DEFAULT_TERMINAL_SETTINGS = {
failsafeMode: false,
prootDebug: false,
// Touch selection settings
touchSelectionTapHoldDuration: 600,
touchSelectionTapHoldDuration: 400,
touchSelectionMoveThreshold: 8,
touchSelectionHandleSize: 24,
touchSelectionHapticFeedback: true,
Expand Down
89 changes: 68 additions & 21 deletions src/components/terminal/terminalManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -765,24 +765,64 @@ class TerminalManager {
}
}, 200);

// Idempotent end-of-session path. Exit JSON, unexpected disconnect, and
// socket errors all funnel here so a missed exit message can't leave a
// zombie tab with a dead PTY.
let sessionFinished = false;
const finishTerminalSession = async ({
message = null,
showToast = true,
errorAlert = null,
} = {}) => {
if (sessionFinished) return;
sessionFinished = true;

try {
terminalComponent.intentionalClose = true;
await this.closeTerminal(terminalId, true);
} catch (error) {
console.error(
`Failed to finish terminal session ${terminalId}:`,
error,
);
}

if (showToast && message) {
toast(message);
}
if (errorAlert) {
alert(strings["error"], errorAlert);
}
};

// Terminal event handlers
terminalComponent.onConnect = () => {
console.log(`Terminal ${terminalId} connected`);
};

terminalComponent.onDisconnect = () => {
console.log(`Terminal ${terminalId} disconnected`);
terminalComponent.onDisconnect = (info = {}) => {
console.log(`Terminal ${terminalId} disconnected`, info);

// User/tab close and dispose intentionally close the socket.
if (info.intentional) return;

// Exit message already drove finishTerminalSession (or is about to).
// Still call finish so a race can't leave the tab open; it's idempotent.
const message = info.processExited ? null : "Terminal session ended";
void finishTerminalSession({
message,
showToast: !info.processExited,
});
};

terminalComponent.onError = (error) => {
console.error(`Terminal ${terminalId} error:`, error);

// Close the terminal and remove the tab
this.closeTerminal(terminalId, true);

// Show alert for connection error
const errorMessage = error?.message || "Connection lost";
alert(strings["error"], `Terminal connection error: ${errorMessage}`);
void finishTerminalSession({
showToast: false,
errorAlert: `Terminal connection error: ${errorMessage}`,
});
};

terminalComponent.onTitleChange = async (title) => {
Expand Down Expand Up @@ -811,20 +851,21 @@ class TerminalManager {
};

terminalComponent.onProcessExit = (exitData) => {
// Format exit message based on exit code and signal
const data = exitData && typeof exitData === "object" ? exitData : {};
let message;
if (exitData.signal) {
message = `Process terminated by signal ${exitData.signal}`;
} else if (exitData.exit_code === 0) {
message = `Process exited successfully (code ${exitData.exit_code})`;
if (data.signal) {
message = `Process terminated by signal ${data.signal}`;
} else if (data.exit_code === 0 || data.exit_code === "0") {
message = `Process exited successfully (code ${data.exit_code})`;
} else if (data.exit_code !== undefined && data.exit_code !== null) {
message = `Process exited with code ${data.exit_code}`;
} else if (typeof exitData === "string" || typeof exitData === "number") {
message = `Process exited with code ${exitData}`;
} else {
message = `Process exited with code ${exitData.exit_code}`;
message = "Process exited";
}

this.closeTerminal(terminalId);
terminalFile._skipTerminalCloseConfirm = true;
terminalFile.remove(true, { ignorePinned: true });
toast(message);
void finishTerminalSession({ message });
};

// Handle acode CLI open commands (OSC 7777)
Expand Down Expand Up @@ -871,18 +912,24 @@ class TerminalManager {
/**
* Close a terminal session
* @param {string} terminalId - Terminal ID
* @param {boolean} removeTab - Also remove the editor tab
* @returns {Promise<void>}
*/
closeTerminal(terminalId, removeTab = false) {
async closeTerminal(terminalId, removeTab = false) {
const terminal = this.terminals.get(terminalId);
if (!terminal) return;

try {
if (terminal.component) {
terminal.component.intentionalClose = true;
}

if (terminal.component.serverMode && terminal.component.pid) {
this.removePersistedSession(terminal.component.pid);
}

// Cleanup resize observer
if (terminal.file._resizeObserver) {
if (terminal.file?._resizeObserver) {
terminal.file._resizeObserver.disconnect();
terminal.file._resizeObserver = null;
}
Expand All @@ -895,14 +942,14 @@ class TerminalManager {
// Dispose terminal component
terminal.component.dispose();

// Remove from map
// Remove from map before tab removal so onclose re-entry is a no-op
this.terminals.delete(terminalId);

// Optionally remove the tab as well
if (removeTab && terminal.file) {
try {
terminal.file._skipTerminalCloseConfirm = true;
terminal.file.remove(true, { ignorePinned: true });
await terminal.file.remove(true, { ignorePinned: true });
} catch (removeError) {
console.error("Error removing terminal tab:", removeError);
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/terminal/terminalTouchSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default class TerminalTouchSelection {
this.terminal = terminal;
this.container = container;
this.options = {
tapHoldDuration: 600,
tapHoldDuration: 400,
moveThreshold: 8,
handleSize: 24,
hapticFeedback: true,
Expand Down