fix: 🐛 clean up sessions tracking in session manager (#3150)

* fix: 🐛 clean up sessions tracking in session manager

* refactor: 💡 PR feedback

* fix: 🐛 add onClose event listener to clean up sessions

* refactor: 💡 update to async/await

* refactor: 💡 move close event after spawn

* refactor: 💡 move onClose session handler to spawn onClose
This commit is contained in:
Cameron Perera authored and GitHub committed 2026-03-05 12:02:38 -06:00
1 parent 780740f2f2
commit b63ea6eee0
3 files changed
+63 -82

No files matched your search

@@ -5,13 +5,12 @@
const { path: boundaryPath } = require('../cli/path.js');
const { spawn, spawnSync } = require('child_process');
const jsonify = require('../utils/jsonify.js');
// You can throw exceptions, or allow them to occur, and this is supported.
// Exceptions thrown in this way will be returned to the UI as an
// IPC rejection. However in most cases, you probably want to return a
// specific exception object of your own design, since Error instances
// originating from the underlying system will be noisey and not very helpful
// originating from the underlying system will be noisy and not very helpful
// to users.
//
//throw new Error('Random error! Should still work!');
@@ -22,57 +21,6 @@ const jsonify = require('../utils/jsonify.js');
// helpful message for users, so it's recommended to craft nice
// POJO representation and throw it or promise->reject it.
module.exports = {
/**
* Spawns an asynchronous child process that is expected to output JSON
* data on either stdout or stderr. The process is allowed to continue
* running after the promise resolves. This function is intended to launch
* the local proxy.
* @param {string[]} command
* @param {string} token
* @param {number} timeout Duration in seconds
* @return {Promise}
*/
spawnAsyncJSONPromise(command, token, timeout) {
return new Promise((resolve, reject) => {
const childProcess = spawn(boundaryPath, command, {
env: {
...process.env,
BOUNDARY_TOKEN: token,
},
timeout: timeout ? timeout * 1000 : undefined,
});
let outputStream = '';
let errorStream = '';
const processData = (data) => {
outputStream += data.toString();
const jsonData = jsonify(outputStream);
if (jsonData) {
childProcess.removeAllListeners();
resolve({ childProcess, response: jsonData });
}
};
childProcess.stdout.on('data', processData);
// Errors are cli returned json objects
const processError = (error) => {
errorStream += error.toString();
const jsonData = jsonify(errorStream);
if (jsonData) {
const error = jsonData.api_error || jsonData.error;
childProcess.removeAllListeners();
reject(new Error(error.message));
}
};
childProcess.stderr.on('error', processError);
childProcess.stderr.on('data', processError);
// Capture spawn errors
childProcess.on('error', (error) => reject(error));
});
},
/**
* Spawn child process and return output immediately.
* This function is intended for non-connection related tasks.
@@ -107,8 +55,9 @@ module.exports = {
* @param path
*/
spawn(command, options, path = boundaryPath) {
const { onClose, ...spawnOptions } = options;
return new Promise((resolve, reject) => {
const childProcess = spawn(path, command, options);
const childProcess = spawn(path, command, spawnOptions);
childProcess.stdout.on('data', (data) => {
resolve({ childProcess, stdout: data.toString() });
});
@@ -123,12 +72,13 @@ module.exports = {
// a timeout occurs so this guarantees we return a response to the caller.
// Otherwise this should not get hit as we should be returning a response
// from one of the handlers above.
childProcess.on('close', () =>
childProcess.on('close', () => {
onClose?.();
resolve({
childProcess,
stderr: JSON.stringify({ error: 'Process was closed.' }),
}),
);
});
});
});
},
};
@@ -6,14 +6,16 @@
const Session = require('./session.js');
class SessionManager {
#sessions = [];
#sessions = new Map();
/**
* Checks for running sessions
* @returns {boolean}
*/
get hasRunningSessions() {
return Boolean(this.#sessions.find((session) => session.isRunning));
return Boolean(
this.#sessions.values().find((session) => session.isRunning),
);
}
/**
@@ -25,25 +27,31 @@ class SessionManager {
* @param {string} host_id
* @param {number} session_max_seconds
*/
start(addr, target_id, token, host_id, session_max_seconds) {
async start(addr, target_id, token, host_id, session_max_seconds) {
const session = new Session(
addr,
target_id,
token,
host_id,
session_max_seconds,
(sessionId) => this.#sessions.delete(sessionId),
);
this.#sessions.push(session);
return session.start();
const sessionDetails = await session.start();
// Store session by id for tracking and stopping later
// Needs to be done after session.start() resolves
// since session id is generated in start()
this.#sessions.set(session.id, session);
return sessionDetails;
}
/**
* Stop a session using identifier.
* Stop a session using identifier
* and remove it from tracking.
* @param {string} session_id
*/
stopById(session_id) {
const session = this.#sessions.find((session) => session.id === session_id);
return session?.stop?.();
const session = this.#sessions.get(session_id);
return session?.stop();
}
/**
@@ -52,16 +60,19 @@ class SessionManager {
* @returns {Session} The session object
*/
getSessionById(sessionId) {
return this.#sessions.find((session) => session.id === sessionId);
return this.#sessions.get(sessionId);
}
/**
* Stop all active and pending target sessions
* Returning Promise.all() ensures all sessions in the list have been
* stopped before calling the next fn
* along with clearing the sessions list.
*/
stopAll() {
return Promise.all(this.#sessions.map((session) => session.stop()));
return Promise.all(
this.#sessions.values().map((session) => session.stop()),
);
}
}
+35 -15
View File
@@ -4,11 +4,9 @@
*/
const sanitizer = require('../utils/sanitizer.js');
const {
spawnAsyncJSONPromise,
spawnSync,
} = require('../helpers/spawn-promise.js');
const { spawnSync, spawn } = require('../helpers/spawn-promise.js');
const log = require('electron-log/main');
const jsonify = require('../utils/jsonify.js');
class Session {
#id;
@@ -19,6 +17,7 @@ class Session {
#targetId;
#proxyDetails;
#sessionMaxSeconds;
#onClose;
/**
* Initialize a session to a controller address
@@ -28,13 +27,15 @@ class Session {
* @param {string} token
* @param {string} hostId
* @param {number} sessionMaxSeconds
* @param {function} onClose
*/
constructor(addr, targetId, token, hostId, sessionMaxSeconds) {
constructor(addr, targetId, token, hostId, sessionMaxSeconds, onClose) {
this.#addr = addr;
this.#targetId = targetId;
this.#token = token;
this.#hostId = hostId;
this.#sessionMaxSeconds = sessionMaxSeconds;
this.#onClose = onClose;
}
/**
@@ -93,18 +94,37 @@ class Session {
* Using cli, initialize a session to a target.
* Tracks local proxy details if successful.
*/
start() {
async start() {
const sanitizedToken = sanitizer.base62EscapeAndValidate(this.#token);
return spawnAsyncJSONPromise(
const options = {
env: {
...process.env,
BOUNDARY_TOKEN: sanitizedToken,
},
timeout: this.#sessionMaxSeconds
? this.#sessionMaxSeconds * 1000
: undefined,
onClose: () => this.#onClose(this.#id),
};
const { childProcess, stdout, stderr } = await spawn(
this.connectCommand,
sanitizedToken,
this.#sessionMaxSeconds,
).then((spawnedSession) => {
this.#process = spawnedSession.childProcess;
this.#proxyDetails = spawnedSession.response;
this.#id = this.#proxyDetails.session_id;
return this.#proxyDetails;
});
options,
);
if (stderr) {
const errorResponse = jsonify(stderr);
const error = errorResponse.api_error || errorResponse.error;
throw new Error(
error?.message ?? 'Unknown error occurred while starting session',
);
}
const response = jsonify(stdout);
this.#process = childProcess;
this.#proxyDetails = response;
this.#id = response.session_id;
return response;
}
/**