200 lines
6.3 KiB
JavaScript
200 lines
6.3 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Transfer-owned SFTP session leases (reference counting).
|
||
|
|
*
|
||
|
|
* File transfers borrow panel (or agent) sftpIds. Without leases, disconnecting
|
||
|
|
* the browser panel calls closeSftp and kills in-flight / paused transfers.
|
||
|
|
*
|
||
|
|
* Model:
|
||
|
|
* - acquire(sftpId, transferId) while a transfer uses that session
|
||
|
|
* - release when the transfer finishes, fails, or is cancelled
|
||
|
|
* - closeSftp with outstanding leases becomes a soft-close: the client stays
|
||
|
|
* in sftpClients until the last transfer releases
|
||
|
|
* - when the last lease is released on a soft-closed session, the real close
|
||
|
|
* runs (pendingHardClose callback / flag)
|
||
|
|
*
|
||
|
|
* This is intentionally independent of UI panels. Browse can go away; transfers
|
||
|
|
* keep their leased sessions until they complete.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {object} SftpTransferSessionLeaseStore
|
||
|
|
* @property {(sftpId: string, transferId: string) => boolean} acquire
|
||
|
|
* @property {(sftpId: string, transferId: string) => { released: boolean, shouldHardClose: boolean, remaining: number }} release
|
||
|
|
* @property {(sftpId: string) => boolean} isHeld
|
||
|
|
* @property {(sftpId: string) => number} getLeaseCount
|
||
|
|
* @property {(sftpId: string) => string[]} listTransferIds
|
||
|
|
* @property {(sftpId: string) => boolean} markSoftClosed
|
||
|
|
* @property {(sftpId: string) => boolean} isSoftClosed
|
||
|
|
* @property {(sftpId: string) => number|undefined} getPendingHardCloseToken
|
||
|
|
* @property {(sftpId: string) => number|undefined} beginHardClose
|
||
|
|
* @property {(sftpId: string, closeToken: number) => boolean} commitHardClose
|
||
|
|
* @property {(sftpId: string) => boolean} isHardCloseCommitted
|
||
|
|
* @property {(sftpId: string) => void} clear
|
||
|
|
* @property {() => void} resetForTests
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @returns {SftpTransferSessionLeaseStore}
|
||
|
|
*/
|
||
|
|
function createSftpTransferSessionLeaseStore() {
|
||
|
|
/** @type {Map<string, Set<string>>} */
|
||
|
|
const leases = new Map();
|
||
|
|
/** @type {Set<string>} */
|
||
|
|
const softClosed = new Set();
|
||
|
|
/** @type {Map<string, { token: number, committed: boolean }>} */
|
||
|
|
const hardCloseClaims = new Map();
|
||
|
|
let nextHardCloseToken = 0;
|
||
|
|
|
||
|
|
function beginHardClose(sftpId) {
|
||
|
|
const existing = hardCloseClaims.get(sftpId);
|
||
|
|
if (existing) return existing.token;
|
||
|
|
nextHardCloseToken += 1;
|
||
|
|
hardCloseClaims.set(sftpId, {
|
||
|
|
token: nextHardCloseToken,
|
||
|
|
committed: false,
|
||
|
|
});
|
||
|
|
return nextHardCloseToken;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getSet(sftpId) {
|
||
|
|
let set = leases.get(sftpId);
|
||
|
|
if (!set) {
|
||
|
|
set = new Set();
|
||
|
|
leases.set(sftpId, set);
|
||
|
|
}
|
||
|
|
return set;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
acquire(sftpId, transferId) {
|
||
|
|
if (!sftpId || !transferId) return false;
|
||
|
|
const closeClaim = hardCloseClaims.get(sftpId);
|
||
|
|
// Before commit, a directory walk's next child may cancel the deferred
|
||
|
|
// close and keep using the live session. After commit, the SSH/SFTP close
|
||
|
|
// has started and this client must never be lent again.
|
||
|
|
if (closeClaim?.committed) return false;
|
||
|
|
if (closeClaim) {
|
||
|
|
hardCloseClaims.delete(sftpId);
|
||
|
|
// The panel's close intent remains sticky across the cancelled close;
|
||
|
|
// the next last release must claim hard-close again.
|
||
|
|
softClosed.add(sftpId);
|
||
|
|
}
|
||
|
|
const set = getSet(sftpId);
|
||
|
|
const before = set.size;
|
||
|
|
set.add(String(transferId));
|
||
|
|
// Keep soft-close sticky. Panel already asked to tear down this session;
|
||
|
|
// new transfers may still borrow it, but the last release must hard-close.
|
||
|
|
// Clearing softClosed here previously leaked sockets forever after
|
||
|
|
// panel close + re-acquire + all transfers finishing.
|
||
|
|
return set.size > before;
|
||
|
|
},
|
||
|
|
|
||
|
|
release(sftpId, transferId) {
|
||
|
|
if (!sftpId || !transferId) {
|
||
|
|
return { released: false, shouldHardClose: false, remaining: 0 };
|
||
|
|
}
|
||
|
|
const set = leases.get(sftpId);
|
||
|
|
if (!set || !set.has(String(transferId))) {
|
||
|
|
return {
|
||
|
|
released: false,
|
||
|
|
shouldHardClose: softClosed.has(sftpId) && (!set || set.size === 0),
|
||
|
|
remaining: set ? set.size : 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
set.delete(String(transferId));
|
||
|
|
const remaining = set.size;
|
||
|
|
if (remaining === 0) {
|
||
|
|
leases.delete(sftpId);
|
||
|
|
const shouldHardClose = softClosed.has(sftpId);
|
||
|
|
if (shouldHardClose) {
|
||
|
|
beginHardClose(sftpId);
|
||
|
|
softClosed.delete(sftpId);
|
||
|
|
}
|
||
|
|
return { released: true, shouldHardClose, remaining: 0 };
|
||
|
|
}
|
||
|
|
return { released: true, shouldHardClose: false, remaining };
|
||
|
|
},
|
||
|
|
|
||
|
|
isHeld(sftpId) {
|
||
|
|
if (!sftpId) return false;
|
||
|
|
const set = leases.get(sftpId);
|
||
|
|
return !!(set && set.size > 0);
|
||
|
|
},
|
||
|
|
|
||
|
|
getLeaseCount(sftpId) {
|
||
|
|
if (!sftpId) return 0;
|
||
|
|
return leases.get(sftpId)?.size ?? 0;
|
||
|
|
},
|
||
|
|
|
||
|
|
listTransferIds(sftpId) {
|
||
|
|
if (!sftpId) return [];
|
||
|
|
return [...(leases.get(sftpId) ?? [])];
|
||
|
|
},
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Panel wants to close this session. If transfers still hold it, defer the
|
||
|
|
* real teardown until the last transfer releases.
|
||
|
|
* @returns {boolean} true when close was deferred (session stays open)
|
||
|
|
*/
|
||
|
|
markSoftClosed(sftpId) {
|
||
|
|
if (!sftpId) return false;
|
||
|
|
if (!this.isHeld(sftpId)) {
|
||
|
|
softClosed.delete(sftpId);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
softClosed.add(sftpId);
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
|
||
|
|
isSoftClosed(sftpId) {
|
||
|
|
return !!sftpId && softClosed.has(sftpId);
|
||
|
|
},
|
||
|
|
|
||
|
|
getPendingHardCloseToken(sftpId) {
|
||
|
|
return hardCloseClaims.get(sftpId)?.token;
|
||
|
|
},
|
||
|
|
|
||
|
|
beginHardClose(sftpId) {
|
||
|
|
if (!sftpId) return undefined;
|
||
|
|
return beginHardClose(sftpId);
|
||
|
|
},
|
||
|
|
|
||
|
|
commitHardClose(sftpId, closeToken) {
|
||
|
|
const claim = hardCloseClaims.get(sftpId);
|
||
|
|
if (!claim || claim.token !== closeToken || claim.committed || this.isHeld(sftpId)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
claim.committed = true;
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
|
||
|
|
isHardCloseCommitted(sftpId) {
|
||
|
|
return hardCloseClaims.get(sftpId)?.committed === true;
|
||
|
|
},
|
||
|
|
|
||
|
|
clear(sftpId) {
|
||
|
|
if (!sftpId) return;
|
||
|
|
leases.delete(sftpId);
|
||
|
|
softClosed.delete(sftpId);
|
||
|
|
hardCloseClaims.delete(sftpId);
|
||
|
|
},
|
||
|
|
|
||
|
|
resetForTests() {
|
||
|
|
leases.clear();
|
||
|
|
softClosed.clear();
|
||
|
|
hardCloseClaims.clear();
|
||
|
|
nextHardCloseToken = 0;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Process-wide store shared by transferBridge and closeSftp. */
|
||
|
|
const sftpTransferSessionLeaseStore = createSftpTransferSessionLeaseStore();
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
createSftpTransferSessionLeaseStore,
|
||
|
|
sftpTransferSessionLeaseStore,
|
||
|
|
};
|