fix: release closed session resources

This commit is contained in:
Andy Dixon
2026-07-29 12:07:59 +01:00
parent 5d5993d9c4
commit f0a1a0b8f2
15 changed files with 809 additions and 114 deletions
+88 -10
View File
@@ -47,6 +47,8 @@
this.sshKeys = new SSHKeyManager(this);
this.triggers = new TriggerEngine(this);
this.leviathan = new LeviathanWorkbench(this);
/** @type {Map<string, Promise<void>>} Idempotent in-flight session closes. */
this._closingSessions = new Map();
this.updateInfo = null;
this.mode = "nexus";
}
@@ -399,20 +401,94 @@
* Disconnects and removes a session without confirmation prompts.
* @param {string} sessionId
*/
async closeSessionImmediate(sessionId) {
closeSessionImmediate(sessionId, options = {}) {
const existing = this._closingSessions.get(sessionId);
if (existing) return existing;
let resolveOperation;
let rejectOperation;
const operation = new Promise((resolve, reject) => {
resolveOperation = resolve;
rejectOperation = reject;
});
this._closingSessions.set(sessionId, operation);
try {
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
} catch (_) {
// Ignore failures; we still want to clean up locally.
this._closeSessionResources(sessionId, options).then(
resolveOperation,
rejectOperation,
);
} catch (err) {
rejectOperation(err);
}
this.terminals.destroyTerminal(sessionId);
this.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
window.NexusTerminal.VNCViewer.destroyViewer(sessionId);
this.tabs.removeTab(sessionId);
this.state.removeSession(sessionId);
const clearClosing = () => {
if (this._closingSessions.get(sessionId) === operation) {
this._closingSessions.delete(sessionId);
}
};
operation.then(clearClosing, clearClosing);
return operation;
}
/**
* Releases every frontend and backend resource owned by a session.
* Local objects are dropped before awaiting the backend so a slow close
* cannot leave renderers, observers or viewers running invisibly.
* @param {string} sessionId
* @param {{removeTab?: boolean, cleanupSplits?: boolean}} options
* @private
*/
async _closeSessionResources(sessionId, options = {}) {
const removeTab = options.removeTab !== false;
const cleanupSplits = options.cleanupSplits !== false;
let splitCleanup = Promise.resolve();
if (cleanupSplits) {
try {
splitCleanup = Promise.resolve(this.splitPanes.cleanup(sessionId));
} catch (_) {
// Continue with the rest of the idempotent local cleanup.
}
}
const localCleanups = [
() => this.sftp.close(sessionId),
() => this.triggers.cleanup(sessionId),
() => this.recordings.cleanup(sessionId),
() => this.terminals.destroyTerminal(sessionId),
() => this.rawViewer.destroyViewer(sessionId),
() => window.NexusTerminal.GitHubViewer?.destroyViewer(sessionId),
() => window.NexusTerminal.VNCViewer?.destroyViewer(sessionId),
() => window.NexusTerminal.IRCViewer?.destroyViewer(sessionId),
() => this.leviathan?.destroyPanel(sessionId),
];
for (const cleanup of localCleanups) {
try {
cleanup();
} catch (_) {
// One faulty viewer must not prevent the remaining resources closing.
}
}
if (removeTab && this.tabs.order.includes(sessionId)) {
this.tabs.removeTab(sessionId);
}
if (this.state.getSession(sessionId)) {
this.state.removeSession(sessionId);
}
this.updateStatusBar();
let backendCleanup = Promise.resolve();
try {
backendCleanup = Promise.resolve(
window.NexusTerminal.wailsService.DisconnectSession(sessionId),
);
} catch (_) {
// Ignore failures; all local resources have already been released.
}
await Promise.allSettled([splitCleanup, backendCleanup]);
}
// ── Keyboard shortcuts ───────────────────────────────────────────
@@ -1829,6 +1905,8 @@
// ── Bootstrap ────────────────────────────────────────────────────────
window.NexusTerminal.App = App;
document.addEventListener("DOMContentLoaded", () => {
try {
const app = new App();
+7
View File
@@ -195,6 +195,13 @@
this.term.dispose();
this.term = null;
}
this.fitAddon = null;
this.searchAddon = null;
this.targets.clear();
this.users.clear();
this.unreadTargets.clear();
this.joinedRooms = [];
this.roomList = [];
this.container.classList.remove("irc-viewer-panel");
this.container.innerHTML = "";
}
+1
View File
@@ -542,6 +542,7 @@
destroyPanel(sessionId) {
this.lastResults.delete(sessionId);
this.autoRunners.delete(sessionId);
this.hexSearches.delete(sessionId);
}
handleTabFocus(sessionId) {
+10
View File
@@ -65,6 +65,16 @@ class RecordingManager {
}
}
/**
* Drops frontend recording state when a session is closed. The backend
* finalises the recorder as part of DisconnectSession.
* @param {string} sessionId
*/
cleanup(sessionId) {
this.activeSessions.delete(sessionId);
this._updateRecordingIndicator(sessionId, false);
}
/**
* Opens the recordings browser modal.
*/
+8 -1
View File
@@ -192,6 +192,7 @@
var state = this.panels.get(sessionId);
if (!state) return;
if (state.cleanupResize) state.cleanupResize();
state.el.remove();
this.panels.delete(sessionId);
@@ -1196,14 +1197,20 @@
}
function onMouseUp() {
cleanupResize();
self._refitTerminal(sessionId);
}
function cleanupResize() {
handle.classList.remove("is-dragging");
document.body.style.userSelect = "";
document.body.style.cursor = "";
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
self._refitTerminal(sessionId);
}
state.cleanupResize = cleanupResize;
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
startX = e.clientX;
+163 -17
View File
@@ -33,6 +33,14 @@ class SplitPaneManager {
* @type {Map<string, Object|null>}
*/
this.trees = new Map();
/**
* Connections started from placeholders but not yet attached to a pane.
* Closing a pane/tab marks its token cancelled so a late backend result
* is disconnected instead of becoming an invisible session.
* @type {Map<string, {ownerSessionId: string, cancelled: boolean, completed: boolean}>}
*/
this.pendingConnections = new Map();
}
// ── Public API ───────────────────────────────────────────────────
@@ -55,8 +63,47 @@ class SplitPaneManager {
* Removes all split state for a session (used on tab close).
* @param {string} sessionId
*/
cleanup(sessionId) {
async cleanup(sessionId) {
var root = this.trees.get(sessionId);
this.trees.delete(sessionId);
var childSessionIds = new Set();
var self = this;
function releaseNode(node) {
if (!node) return;
if (node.type === 'leaf') {
self._cancelPendingConnection(node.paneId);
if (node.sessionId && node.sessionId !== sessionId) {
childSessionIds.add(node.sessionId);
}
if (node.el) node.el._splitNode = null;
node.parent = null;
return;
}
if (node.cancelDrag) node.cancelDrag();
releaseNode(node.first);
releaseNode(node.second);
if (node.el) node.el._splitNode = null;
node.parent = null;
}
releaseNode(root);
// A connection may still be resolving before its leaf has a session ID.
// Its token remains captured by the promise and will disconnect any late
// result after _cancelPendingConnection marks it cancelled.
for (var [paneId, token] of this.pendingConnections) {
if (token.ownerSessionId === sessionId) {
token.cancelled = true;
this.pendingConnections.delete(paneId);
}
}
await Promise.allSettled(Array.from(childSessionIds, function(childId) {
return self.app.closeSessionImmediate(childId, {
removeTab: false,
cleanupSplits: false
});
}));
}
/**
@@ -265,15 +312,27 @@ class SplitPaneManager {
var branch = leaf.parent;
var sibling = (branch.first === leaf) ? branch.second : branch.first;
// If the closing pane has a terminal session, destroy it (but not the owner session)
this._cancelPendingConnection(leaf.paneId);
// If the closing pane has a child session, release both its frontend
// renderer and backend connection. The owner pane cannot be closed
// independently because it provides the tab's identity.
if (leaf.sessionId && leaf.sessionId !== ownerSessionId) {
this.app.terminals.destroyTerminal(leaf.sessionId);
this.app.closeSessionImmediate(leaf.sessionId, {
removeTab: false,
cleanupSplits: false
});
leaf.sessionId = null;
}
if (branch.cancelDrag) branch.cancelDrag();
// Replace the branch element with the sibling element in the DOM
sibling.el.style.flex = branch.el.style.flex || '1';
branch.el.parentNode.insertBefore(sibling.el, branch.el);
branch.el.remove();
leaf.el._splitNode = null;
branch.el._splitNode = null;
// Remove close button from sibling if it's now the only pane
sibling.parent = branch.parent;
@@ -312,18 +371,37 @@ class SplitPaneManager {
*/
_setupDividerDrag(divider, branch) {
var self = this;
var onMouseMove = null;
var onMouseUp = null;
var bodyClass = null;
function stopDrag(refit) {
if (bodyClass) document.body.classList.remove(bodyClass);
divider.classList.remove('is-dragging');
if (onMouseMove) document.removeEventListener('mousemove', onMouseMove);
if (onMouseUp) document.removeEventListener('mouseup', onMouseUp);
onMouseMove = null;
onMouseUp = null;
bodyClass = null;
if (refit) self._refitTree(branch);
}
branch.cancelDrag = function() {
stopDrag(false);
};
divider.addEventListener('mousedown', function(e) {
e.preventDefault();
stopDrag(false);
var isHorizontal = branch.direction === 'horizontal';
var bodyClass = isHorizontal ? 'split-resizing-row' : 'split-resizing';
bodyClass = isHorizontal ? 'split-resizing-row' : 'split-resizing';
document.body.classList.add(bodyClass);
divider.classList.add('is-dragging');
var containerRect = branch.el.getBoundingClientRect();
var dividerSize = isHorizontal ? 4 : 4; // divider thickness
function onMouseMove(e) {
onMouseMove = function(e) {
var totalSize, mousePos;
if (isHorizontal) {
totalSize = containerRect.height - dividerSize;
@@ -343,17 +421,11 @@ class SplitPaneManager {
// Refit terminals in this branch
self._refitTree(branch);
}
};
function onMouseUp() {
document.body.classList.remove(bodyClass);
divider.classList.remove('is-dragging');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Final refit
self._refitTree(branch);
}
onMouseUp = function() {
stopDrag(true);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
@@ -443,17 +515,29 @@ class SplitPaneManager {
*/
_connectInPane(paneEl, profile, ownerSessionId) {
var self = this;
var token = this._beginPendingConnection(paneEl, ownerSessionId);
// Remove placeholder
var placeholder = paneEl.querySelector('.split-pane-placeholder');
if (placeholder) placeholder.remove();
// Connect via backend
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) {
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(async function(sessionInfo) {
if (!self._completePendingConnection(paneEl, ownerSessionId, token)) {
try {
await window.NexusTerminal.wailsService.DisconnectSession(sessionInfo.id);
} catch (_) {
// The backend may already have completed the close.
}
return;
}
self._attachTerminalToPane(paneEl, sessionInfo, profile);
self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…');
}).catch(function(err) {
if (!self._completePendingConnection(paneEl, ownerSessionId, token)) {
return;
}
self.app.notifications.error('Connection failed: ' + err);
// Re-render placeholder on failure
self._renderPlaceholder(paneEl, ownerSessionId);
@@ -468,10 +552,19 @@ class SplitPaneManager {
*/
_connectLocalInPane(paneEl, ownerSessionId) {
var self = this;
var token = this._beginPendingConnection(paneEl, ownerSessionId);
var placeholder = paneEl.querySelector('.split-pane-placeholder');
if (placeholder) placeholder.remove();
window.NexusTerminal.wailsService.ConnectLocal().then(function(sessionInfo) {
window.NexusTerminal.wailsService.ConnectLocal().then(async function(sessionInfo) {
if (!self._completePendingConnection(paneEl, ownerSessionId, token)) {
try {
await window.NexusTerminal.wailsService.DisconnectSession(sessionInfo.id);
} catch (_) {
// The backend may already have completed the close.
}
return;
}
var prefs = self.app.state.preferences || {};
self._attachTerminalToPane(paneEl, sessionInfo, {
terminalFontFamily: prefs.defaultFontFamily,
@@ -481,6 +574,9 @@ class SplitPaneManager {
});
self.app.notifications.info('Opening local terminal…');
}).catch(function(err) {
if (!self._completePendingConnection(paneEl, ownerSessionId, token)) {
return;
}
self.app.notifications.error('Local terminal failed: ' + err);
self._renderPlaceholder(paneEl, ownerSessionId);
});
@@ -513,6 +609,53 @@ class SplitPaneManager {
this.app.terminals.createTerminal(sessionInfo.id, termContainer.id, terminalProfile);
}
/**
* Tracks an asynchronous connection attempt owned by a pane.
* @private
*/
_beginPendingConnection(paneEl, ownerSessionId) {
var paneId = paneEl.dataset.paneId;
this._cancelPendingConnection(paneId);
var token = {
ownerSessionId: ownerSessionId,
cancelled: false,
completed: false
};
this.pendingConnections.set(paneId, token);
return token;
}
/**
* Returns true only if a connection result still belongs to a live pane.
* @private
*/
_completePendingConnection(paneEl, ownerSessionId, token) {
if (token.completed) return false;
token.completed = true;
var paneId = paneEl.dataset.paneId;
if (this.pendingConnections.get(paneId) === token) {
this.pendingConnections.delete(paneId);
}
var root = this.trees.get(ownerSessionId);
var leaf = this._findLeafById(root, paneId);
return !token.cancelled &&
paneEl.isConnected !== false &&
leaf &&
leaf.el === paneEl;
}
/**
* Cancels a pending pane connection without waiting for the backend call.
* A late successful result will disconnect itself.
* @private
*/
_cancelPendingConnection(paneId) {
var token = this.pendingConnections.get(paneId);
if (!token) return;
token.cancelled = true;
this.pendingConnections.delete(paneId);
}
// ── Tree traversal helpers ───────────────────────────────────────
/**
@@ -594,6 +737,9 @@ class SplitPaneManager {
_addCloseButton(paneEl, ownerSessionId) {
// Don't add if already present
if (paneEl.querySelector(':scope > .split-pane-close')) return;
// The owner session supplies the tab/panel identity. Allowing that leaf
// to close would leave an invisible backend session behind.
if (paneEl._splitNode && paneEl._splitNode.sessionId === ownerSessionId) return;
var btn = document.createElement('button');
btn.className = 'split-pane-close';
+3
View File
@@ -164,6 +164,9 @@
*/
removeSession(id) {
this.sessions.delete(id);
if (this.broadcastTargets) {
this.broadcastTargets.delete(id);
}
if (this.activeSessionId === id) {
// Activate the most recent remaining session, or null
const remaining = [...this.sessions.keys()];
+3 -20
View File
@@ -182,19 +182,7 @@
if (!ok) return;
}
try {
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
} catch (_) {
// Session may already be closed — carry on
}
this.app.terminals.destroyTerminal(sessionId);
this.app.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
window.NexusTerminal.IRCViewer.destroyViewer(sessionId);
this.app.leviathan?.destroyPanel(sessionId);
this.removeTab(sessionId);
this.app.state.removeSession(sessionId);
await this.app.closeSessionImmediate(sessionId);
}
/**
@@ -852,13 +840,8 @@
const session = this.app.state.getSession(sessionId);
if (!session || !session.profileId) return;
// Close the current tab, then open a fresh connection
this.app.terminals.destroyTerminal(sessionId);
this.app.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
this.app.state.removeSession(sessionId);
this.removeTab(sessionId);
// Fully close the old session before opening a fresh connection.
await this.app.closeSessionImmediate(sessionId);
await this.app.connectProfile(session.profileId);
}
+44 -8
View File
@@ -74,6 +74,9 @@
this.container = container;
this.profile = profile;
this.rfb = null;
this._rfbListeners = [];
this._credentialDialog = null;
this._destroyed = false;
this._buildUI();
}
@@ -132,6 +135,7 @@
* @param {string} wsURL - WebSocket URL (ws://127.0.0.1:<port>)
*/
connect(wsURL) {
if (this._destroyed) return;
if (!window.noVNC || !window.noVNC.RFB) {
this.statusEl.textContent = "Error: noVNC not loaded";
this.statusEl.classList.add("vnc-status-error");
@@ -154,13 +158,13 @@
this.rfb.compressionLevel = 2;
// Event handlers
this.rfb.addEventListener("connect", () => {
this._addRFBListener("connect", () => {
this.statusEl.textContent = "Connected";
this.statusEl.classList.remove("vnc-status-error");
this.statusEl.classList.add("vnc-status-connected");
});
this.rfb.addEventListener("disconnect", (ev) => {
this._addRFBListener("disconnect", (ev) => {
const clean = ev.detail.clean;
this.statusEl.textContent = clean
? "Disconnected"
@@ -171,13 +175,13 @@
}
});
this.rfb.addEventListener("credentialsrequired", () => {
this._addRFBListener("credentialsrequired", () => {
this.statusEl.textContent = "Credentials required";
// TODO: prompt for VNC password via modal
this._promptCredentials();
});
this.rfb.addEventListener("desktopname", (ev) => {
this._addRFBListener("desktopname", (ev) => {
this.statusEl.textContent = "Connected — " + ev.detail.name;
});
} catch (err) {
@@ -192,6 +196,11 @@
}
}
_addRFBListener(name, handler) {
this.rfb.addEventListener(name, handler);
this._rfbListeners.push({ name, handler });
}
async _syncClipboard() {
if (!this.rfb) return;
try {
@@ -213,6 +222,9 @@
}
_promptCredentials() {
if (this._destroyed) return;
this._closeCredentialDialog();
// Build a simple modal for VNC password
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
@@ -242,13 +254,21 @@
const input = modal.querySelector("#vnc-password-input");
const submit = modal.querySelector("#vnc-password-submit");
const cancel = modal.querySelector("#vnc-password-cancel");
const dialog = {
backdrop,
modal,
removeTimer: null,
focusTimer: null,
};
this._credentialDialog = dialog;
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => {
backdrop.remove();
modal.remove();
dialog.removeTimer = setTimeout(() => {
if (this._credentialDialog === dialog) {
this._closeCredentialDialog();
}
}, 200);
};
@@ -269,7 +289,17 @@
if (e.key === "Escape") cancel.click();
});
setTimeout(() => input.focus(), 50);
dialog.focusTimer = setTimeout(() => input.focus(), 50);
}
_closeCredentialDialog() {
const dialog = this._credentialDialog;
if (!dialog) return;
if (dialog.removeTimer) clearTimeout(dialog.removeTimer);
if (dialog.focusTimer) clearTimeout(dialog.focusTimer);
dialog.backdrop.remove();
dialog.modal.remove();
this._credentialDialog = null;
}
resize() {
@@ -281,7 +311,13 @@
}
destroy() {
this._destroyed = true;
this._closeCredentialDialog();
if (this.rfb) {
for (const { name, handler } of this._rfbListeners) {
this.rfb.removeEventListener(name, handler);
}
this._rfbListeners = [];
this.rfb.disconnect();
this.rfb = null;
}