241 lines
8.2 KiB
JavaScript
241 lines
8.2 KiB
JavaScript
|
|
/**
|
||
|
|
* Correlate overlapping terminal starts that share one UI sessionId.
|
||
|
|
* A higher bootEpoch owns the registry slot; mismatched closes are no-ops.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const pendingBootAborts = new Map();
|
||
|
|
const latestBootEpochs = new Map();
|
||
|
|
|
||
|
|
function normalizeBootEpoch(bootEpoch) {
|
||
|
|
if (!Number.isFinite(bootEpoch)) return undefined;
|
||
|
|
return Number(bootEpoch);
|
||
|
|
}
|
||
|
|
|
||
|
|
function attachBootEpoch(session, bootEpoch) {
|
||
|
|
const normalized = normalizeBootEpoch(bootEpoch);
|
||
|
|
if (normalized === undefined || !session || typeof session !== "object") return session;
|
||
|
|
session.bootEpoch = normalized;
|
||
|
|
return session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Register an AbortController for an in-flight start that has not yet claimed
|
||
|
|
* the sessions registry (e.g. SSH passphrase prompts before shell open).
|
||
|
|
* A newer register for the same sessionId aborts the older controller.
|
||
|
|
*/
|
||
|
|
function registerPendingBootAbort(sessionId, bootEpoch) {
|
||
|
|
if (!sessionId) return new AbortController();
|
||
|
|
const existing = pendingBootAborts.get(sessionId);
|
||
|
|
if (existing) {
|
||
|
|
try { existing.controller.abort(); } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
const controller = new AbortController();
|
||
|
|
const normalized = normalizeBootEpoch(bootEpoch);
|
||
|
|
if (normalized !== undefined) {
|
||
|
|
latestBootEpochs.set(sessionId, Math.max(latestBootEpochs.get(sessionId) ?? normalized, normalized));
|
||
|
|
}
|
||
|
|
pendingBootAborts.set(sessionId, {
|
||
|
|
controller,
|
||
|
|
bootEpoch: normalized,
|
||
|
|
});
|
||
|
|
return controller;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Abort a pending boot. When bootEpoch is provided, only abort if the pending
|
||
|
|
* entry is not newer than that epoch (so a stale close cannot kill a reconnect).
|
||
|
|
*/
|
||
|
|
function abortPendingBoot(sessionId, bootEpoch) {
|
||
|
|
if (!sessionId) return false;
|
||
|
|
const pending = pendingBootAborts.get(sessionId);
|
||
|
|
if (!pending) return false;
|
||
|
|
const requested = normalizeBootEpoch(bootEpoch);
|
||
|
|
if (
|
||
|
|
requested !== undefined
|
||
|
|
&& pending.bootEpoch !== undefined
|
||
|
|
&& pending.bootEpoch > requested
|
||
|
|
) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
try { pending.controller.abort(); } catch { /* ignore */ }
|
||
|
|
if (pendingBootAborts.get(sessionId) === pending) {
|
||
|
|
pendingBootAborts.delete(sessionId);
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasPendingBootAfter(sessionId, bootEpoch) {
|
||
|
|
if (!sessionId) return false;
|
||
|
|
const pending = pendingBootAborts.get(sessionId);
|
||
|
|
const requested = normalizeBootEpoch(bootEpoch);
|
||
|
|
return Boolean(
|
||
|
|
pending
|
||
|
|
&& requested !== undefined
|
||
|
|
&& pending.bootEpoch !== undefined
|
||
|
|
&& pending.bootEpoch > requested
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasNewerBootEpoch(sessionId, bootEpoch) {
|
||
|
|
if (!sessionId) return false;
|
||
|
|
const requested = normalizeBootEpoch(bootEpoch);
|
||
|
|
const latest = latestBootEpochs.get(sessionId);
|
||
|
|
return requested !== undefined && latest !== undefined && latest > requested;
|
||
|
|
}
|
||
|
|
|
||
|
|
function forgetBootEpoch(sessionId, bootEpoch) {
|
||
|
|
if (!sessionId) return;
|
||
|
|
const requested = normalizeBootEpoch(bootEpoch);
|
||
|
|
const latest = latestBootEpochs.get(sessionId);
|
||
|
|
if (requested === undefined || latest === undefined || latest <= requested) {
|
||
|
|
latestBootEpochs.delete(sessionId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearPendingBootAbort(sessionId, controller) {
|
||
|
|
if (!sessionId || !controller) return;
|
||
|
|
const pending = pendingBootAborts.get(sessionId);
|
||
|
|
if (pending?.controller === controller) {
|
||
|
|
pendingBootAborts.delete(sessionId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Best-effort teardown for a session object that lost its registry slot to a
|
||
|
|
* newer bootEpoch. Must not look the session up by ID (the map already points
|
||
|
|
* at the replacement).
|
||
|
|
*
|
||
|
|
* @param {object} session
|
||
|
|
* @param {string} [sessionId] Registry key the displaced session previously owned.
|
||
|
|
*/
|
||
|
|
function disposeDisplacedSessionResources(session, sessionId) {
|
||
|
|
if (!session || session._displacedDisposed) return;
|
||
|
|
session._displacedDisposed = true;
|
||
|
|
try { session.zmodemSentry?.cancel?.(); } catch { /* ignore */ }
|
||
|
|
try { session.discardPendingData?.(); } catch { /* ignore */ }
|
||
|
|
try { session.releaseTelnetGeneration?.(); } catch { /* ignore */ }
|
||
|
|
// Exit handlers for Mosh/ET/serial bail out once the registry no longer
|
||
|
|
// points at them, so they never stop their own log stream. Stop it here
|
||
|
|
// before a replacement (possibly without logging) keeps appendData-ing
|
||
|
|
// into the displaced file. Claim runs before the replacement starts its
|
||
|
|
// own stream, so a token-less stop still targets this boot's entry.
|
||
|
|
if (sessionId) {
|
||
|
|
try {
|
||
|
|
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||
|
|
const logToken = session.logStreamToken ?? session._logStreamToken;
|
||
|
|
void sessionLogStreamManager.stopStream(sessionId, logToken);
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
if (session.stream) {
|
||
|
|
try { session.stream.close(); } catch { /* ignore */ }
|
||
|
|
if (session.connRef) {
|
||
|
|
try {
|
||
|
|
const { releaseConnectionRef } = require("./sshConnectionPool.cjs");
|
||
|
|
releaseConnectionRef(session);
|
||
|
|
} catch {
|
||
|
|
try { session.conn?.end?.(); } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
try { session.conn?.end?.(); } catch { /* ignore */ }
|
||
|
|
for (const hop of session.chainConnections || []) {
|
||
|
|
try { hop.end?.(); } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if (session.proc) {
|
||
|
|
try { session.proc.kill(); } catch { /* ignore */ }
|
||
|
|
try { session.moshStatsConn?.end?.(); } catch { /* ignore */ }
|
||
|
|
try { session.etStatsConn?.end?.(); } catch { /* ignore */ }
|
||
|
|
// ET stores private HOME/key/askpass paths on the session; normal close
|
||
|
|
// and owning-exit handlers clean them, but displaced owners skip those
|
||
|
|
// paths once the registry slot is overwritten.
|
||
|
|
if (Array.isArray(session.externalAuthArtifacts) && !session.externalAuthArtifactsCleaned) {
|
||
|
|
session.externalAuthArtifactsCleaned = true;
|
||
|
|
const fs = require("node:fs");
|
||
|
|
for (const artifactPath of session.externalAuthArtifacts) {
|
||
|
|
try {
|
||
|
|
fs.rmSync(artifactPath, { recursive: true, force: true });
|
||
|
|
} catch {
|
||
|
|
// ignore cleanup failures
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if (session.socket) {
|
||
|
|
try { session.socket.destroy(); } catch { /* ignore */ }
|
||
|
|
} else if (session.serialPort) {
|
||
|
|
try { session.serialPort.close(); } catch { /* ignore */ }
|
||
|
|
} else if (session.chainConnections) {
|
||
|
|
for (const hop of session.chainConnections) {
|
||
|
|
try { hop.end?.(); } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Best effort only.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @returns {{ ok: true, displaced?: object } | { ok: false, reason: "superseded" }}
|
||
|
|
*/
|
||
|
|
function claimSessionSlot(sessions, sessionId, session, bootEpoch) {
|
||
|
|
if (!sessions || typeof sessions.get !== "function" || typeof sessions.set !== "function") {
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
const normalized = normalizeBootEpoch(bootEpoch);
|
||
|
|
const existing = sessions.get(sessionId);
|
||
|
|
if (
|
||
|
|
existing
|
||
|
|
&& existing !== session
|
||
|
|
&& Number.isFinite(existing.bootEpoch)
|
||
|
|
&& normalized !== undefined
|
||
|
|
&& normalized < existing.bootEpoch
|
||
|
|
) {
|
||
|
|
return { ok: false, reason: "superseded" };
|
||
|
|
}
|
||
|
|
if (normalized !== undefined) {
|
||
|
|
session.bootEpoch = normalized;
|
||
|
|
latestBootEpochs.set(sessionId, Math.max(latestBootEpochs.get(sessionId) ?? normalized, normalized));
|
||
|
|
}
|
||
|
|
let displaced;
|
||
|
|
// When a newer boot replaces an older registry entry, mark and return the
|
||
|
|
// old object so the caller can tear down its transport without touching the
|
||
|
|
// replacement slot.
|
||
|
|
if (
|
||
|
|
existing
|
||
|
|
&& existing !== session
|
||
|
|
&& Number.isFinite(existing.bootEpoch)
|
||
|
|
&& normalized !== undefined
|
||
|
|
&& normalized > existing.bootEpoch
|
||
|
|
) {
|
||
|
|
existing.supersededByBootEpoch = normalized;
|
||
|
|
existing.closed = true;
|
||
|
|
displaced = existing;
|
||
|
|
}
|
||
|
|
sessions.set(sessionId, session);
|
||
|
|
if (displaced) {
|
||
|
|
disposeDisplacedSessionResources(displaced, sessionId);
|
||
|
|
}
|
||
|
|
return displaced ? { ok: true, displaced } : { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function sessionMatchesBootEpoch(session, bootEpoch) {
|
||
|
|
const normalized = normalizeBootEpoch(bootEpoch);
|
||
|
|
if (normalized === undefined) return true;
|
||
|
|
if (!session || !Number.isFinite(session.bootEpoch)) return true;
|
||
|
|
return session.bootEpoch === normalized;
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
abortPendingBoot,
|
||
|
|
attachBootEpoch,
|
||
|
|
claimSessionSlot,
|
||
|
|
clearPendingBootAbort,
|
||
|
|
disposeDisplacedSessionResources,
|
||
|
|
forgetBootEpoch,
|
||
|
|
hasPendingBootAfter,
|
||
|
|
hasNewerBootEpoch,
|
||
|
|
normalizeBootEpoch,
|
||
|
|
registerPendingBootAbort,
|
||
|
|
sessionMatchesBootEpoch,
|
||
|
|
};
|