Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
/**
|
|
* Transfer channel pool (FileZilla-style concurrency, not a second SSH stack).
|
|
*
|
|
* Bulk transfers share the main-process SSH transport registry. This pool only
|
|
* limits how many SFTP channels (sftpIds) may be open per host for parallel
|
|
* transfers, and reuses a busy-vs-idle slot within that cap.
|
|
*
|
|
* A released channel stays available for a short bounded idle window so a
|
|
* directory of sequential small files does not reopen one SFTP channel per
|
|
* file. The global idle cap prevents many visited hosts from retaining an
|
|
* unbounded number of channels; SSH transports remain owned by the unified
|
|
* main-process registry.
|
|
*/
|
|
|
|
export const DEFAULT_TRANSFER_CONNECTIONS_PER_HOST = 2;
|
|
export const MIN_TRANSFER_CONNECTIONS_PER_HOST = 1;
|
|
export const MAX_TRANSFER_CONNECTIONS_PER_HOST = 4;
|
|
|
|
export const DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS = 5_000;
|
|
export const DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS = 16;
|
|
|
|
export type TransferPoolOpenFn = (poolKey: string) => Promise<string>;
|
|
export type TransferPoolCloseFn = (sftpId: string) => void | Promise<void>;
|
|
export type TransferPoolSessionLeaseFn = (sftpId: string, leaseId: string) => void | Promise<void>;
|
|
|
|
export interface TransferConnectionLease {
|
|
sftpId: string;
|
|
poolKey: string;
|
|
/** Drop the holder count; last holder enters the short idle park. */
|
|
release: () => void;
|
|
/** Drop holder, remove from pool, and close — use when the session is dead. */
|
|
discard: () => void;
|
|
}
|
|
|
|
interface PoolSlot {
|
|
sftpId: string;
|
|
/** Main-process hold that keeps this SFTP channel alive between child files. */
|
|
sessionLeaseId: string;
|
|
holders: Set<string>;
|
|
lastUsedAt: number;
|
|
idleSince?: number;
|
|
idleOrder?: number;
|
|
idleTimer?: unknown;
|
|
/** Session is dead; do not hand out to new transfers. Close when idle. */
|
|
unhealthy?: boolean;
|
|
opening?: Promise<string>;
|
|
}
|
|
|
|
export interface TransferConnectionPoolOptions {
|
|
maxPerHost?: number;
|
|
idleTtlMs?: number;
|
|
/** Global cap across all host pools; oldest idle channels are evicted first. */
|
|
maxIdleConnections?: number;
|
|
closeSession?: TransferPoolCloseFn;
|
|
retainSession?: TransferPoolSessionLeaseFn;
|
|
releaseSession?: TransferPoolSessionLeaseFn;
|
|
now?: () => number;
|
|
/** Deterministic timer hooks for tests. */
|
|
setTimeoutFn?: (callback: () => void, delayMs: number) => unknown;
|
|
clearTimeoutFn?: (handle: unknown) => void;
|
|
}
|
|
|
|
export interface TransferPoolKeyInput {
|
|
hostId?: string;
|
|
hostname?: string;
|
|
port?: number;
|
|
username?: string;
|
|
protocol?: string;
|
|
sftpSudo?: boolean;
|
|
/** Full resolved transport identity; kept private inside the in-memory pool. */
|
|
connectionOptions?: NetcattySSHOptions;
|
|
}
|
|
|
|
export interface TransferPoolKeyCache {
|
|
get(host: object, createInput: () => TransferPoolKeyInput): Promise<string>;
|
|
}
|
|
|
|
/**
|
|
* Resolving a transfer identity includes credential expansion and SHA-256.
|
|
* Directory transfers may acquire a lease once per file, so cache that work
|
|
* by the immutable Host object for the lifetime of the current vault inputs.
|
|
*/
|
|
export function createTransferPoolKeyCache(
|
|
build: (input: TransferPoolKeyInput) => Promise<string> = buildTransferPoolKey,
|
|
): TransferPoolKeyCache {
|
|
const cache = new WeakMap<object, Promise<string>>();
|
|
return {
|
|
get(host, createInput) {
|
|
const existing = cache.get(host);
|
|
if (existing) return existing;
|
|
const pending = Promise.resolve().then(() => build(createInput()));
|
|
cache.set(host, pending);
|
|
void pending.catch(() => {
|
|
if (cache.get(host) === pending) cache.delete(host);
|
|
});
|
|
return pending;
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface TransferConnectionPool {
|
|
acquire(poolKey: string, transferId: string, open: TransferPoolOpenFn): Promise<TransferConnectionLease>;
|
|
release(poolKey: string, sftpId: string, transferId: string): void;
|
|
/** Remove a dead session from the pool and close it (best-effort). */
|
|
discard(sftpId: string): void;
|
|
getStats(poolKey?: string): {
|
|
poolKeys: number;
|
|
connections: number;
|
|
busy: number;
|
|
idle: number;
|
|
holders: number;
|
|
pendingOpenLocks: number;
|
|
};
|
|
/** Close channels whose idle deadline has passed. */
|
|
closeIdle(now?: number): Promise<number>;
|
|
closeAll(): Promise<void>;
|
|
setMaxPerHost(max: number): void;
|
|
setIdleTtlMs(ms: number): void;
|
|
getIdleTtlMs(): number;
|
|
}
|
|
|
|
function normalizeMaxPerHost(value: number | undefined): number {
|
|
if (!Number.isInteger(value)) return DEFAULT_TRANSFER_CONNECTIONS_PER_HOST;
|
|
return Math.min(
|
|
MAX_TRANSFER_CONNECTIONS_PER_HOST,
|
|
Math.max(MIN_TRANSFER_CONNECTIONS_PER_HOST, value as number),
|
|
);
|
|
}
|
|
|
|
function normalizeIdleTtlMs(value: number | undefined): number {
|
|
if (value === undefined) return DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS;
|
|
if (!Number.isFinite(value)) return DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS;
|
|
return Math.max(0, Math.floor(value));
|
|
}
|
|
|
|
function normalizeMaxIdleConnections(value: number | undefined): number {
|
|
if (value === undefined) return DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS;
|
|
if (!Number.isFinite(value)) return DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS;
|
|
return Math.max(0, Math.floor(value));
|
|
}
|
|
|
|
export function createTransferConnectionPool(
|
|
options: TransferConnectionPoolOptions = {},
|
|
): TransferConnectionPool {
|
|
let maxPerHost = normalizeMaxPerHost(options.maxPerHost);
|
|
let idleTtlMs = normalizeIdleTtlMs(options.idleTtlMs);
|
|
const maxIdleConnections = normalizeMaxIdleConnections(options.maxIdleConnections);
|
|
const closeSession = options.closeSession;
|
|
const retainSession = options.retainSession;
|
|
const releaseSession = options.releaseSession;
|
|
const now = options.now ?? (() => Date.now());
|
|
const setTimeoutFn = options.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
const clearTimeoutFn = options.clearTimeoutFn ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
let nextIdleOrder = 1;
|
|
|
|
/** poolKey -> open slots for that host endpoint */
|
|
const pools = new Map<string, PoolSlot[]>();
|
|
/** Serialize opens per host so we never exceed maxPerHost under concurrency */
|
|
const openLocks = new Map<string, Promise<void>>();
|
|
|
|
const getList = (poolKey: string): PoolSlot[] => {
|
|
let list = pools.get(poolKey);
|
|
if (!list) {
|
|
list = [];
|
|
pools.set(poolKey, list);
|
|
}
|
|
return list;
|
|
};
|
|
|
|
const withOpenLock = async <T>(poolKey: string, work: () => Promise<T>): Promise<T> => {
|
|
const previous = openLocks.get(poolKey) ?? Promise.resolve();
|
|
let releaseLock!: () => void;
|
|
const gate = new Promise<void>((resolve) => { releaseLock = resolve; });
|
|
// Chain waiters so concurrent acquires never exceed maxPerHost.
|
|
const tail = previous.catch(() => {}).then(() => gate);
|
|
openLocks.set(poolKey, tail);
|
|
await previous.catch(() => {});
|
|
try {
|
|
return await work();
|
|
} finally {
|
|
releaseLock();
|
|
// A later waiter replaces our tail. Only the last waiter may remove the
|
|
// per-host serialization entry, otherwise concurrent opens can overlap.
|
|
if (openLocks.get(poolKey) === tail) openLocks.delete(poolKey);
|
|
}
|
|
};
|
|
|
|
const clearIdleTimer = (slot: PoolSlot) => {
|
|
if (slot.idleTimer === undefined) return;
|
|
clearTimeoutFn(slot.idleTimer);
|
|
slot.idleTimer = undefined;
|
|
};
|
|
|
|
const detachSlot = (poolKey: string, list: PoolSlot[], idx: number): PoolSlot | undefined => {
|
|
const [slot] = list.splice(idx, 1);
|
|
if (slot) clearIdleTimer(slot);
|
|
if (list.length === 0) pools.delete(poolKey);
|
|
else pools.set(poolKey, list);
|
|
return slot;
|
|
};
|
|
|
|
const closeSlot = async (slot: PoolSlot) => {
|
|
if (releaseSession) {
|
|
try {
|
|
await releaseSession(slot.sftpId, slot.sessionLeaseId);
|
|
} catch {
|
|
// best-effort; the explicit close below is still required
|
|
}
|
|
}
|
|
try {
|
|
await closeSession?.(slot.sftpId);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
};
|
|
|
|
const closeSessionBestEffort = (slot: PoolSlot) => {
|
|
void closeSlot(slot);
|
|
};
|
|
|
|
const removeAndCloseSlot = (poolKey: string, list: PoolSlot[], idx: number) => {
|
|
const slot = detachSlot(poolKey, list, idx);
|
|
if (slot) {
|
|
closeSessionBestEffort(slot);
|
|
}
|
|
};
|
|
|
|
const createRetainedSlot = async (sftpId: string, transferId: string): Promise<PoolSlot> => {
|
|
const sessionLeaseId = `pool:${sftpId}`;
|
|
try {
|
|
await retainSession?.(sftpId, sessionLeaseId);
|
|
} catch (error) {
|
|
try { await closeSession?.(sftpId); } catch { /* best-effort */ }
|
|
throw error;
|
|
}
|
|
return {
|
|
sftpId,
|
|
sessionLeaseId,
|
|
holders: new Set([transferId]),
|
|
lastUsedAt: now(),
|
|
};
|
|
};
|
|
|
|
const scheduleIdleClose = (poolKey: string, slot: PoolSlot, delayMs?: number) => {
|
|
clearIdleTimer(slot);
|
|
if (slot.holders.size > 0) return;
|
|
const list = pools.get(poolKey);
|
|
const idx = list?.indexOf(slot) ?? -1;
|
|
if (!list || idx < 0) return;
|
|
if (slot.unhealthy || idleTtlMs <= 0) {
|
|
removeAndCloseSlot(poolKey, list, idx);
|
|
return;
|
|
}
|
|
|
|
const remaining = delayMs ?? Math.max(0, (slot.idleSince ?? now()) + idleTtlMs - now());
|
|
if (remaining <= 0) {
|
|
removeAndCloseSlot(poolKey, list, idx);
|
|
return;
|
|
}
|
|
const handle = setTimeoutFn(() => {
|
|
slot.idleTimer = undefined;
|
|
const currentList = pools.get(poolKey);
|
|
const currentIdx = currentList?.indexOf(slot) ?? -1;
|
|
if (!currentList || currentIdx < 0 || slot.holders.size > 0) return;
|
|
const deadline = (slot.idleSince ?? slot.lastUsedAt) + idleTtlMs;
|
|
const remainingAtFire = deadline - now();
|
|
if (remainingAtFire > 0) {
|
|
scheduleIdleClose(poolKey, slot, remainingAtFire);
|
|
return;
|
|
}
|
|
removeAndCloseSlot(poolKey, currentList, currentIdx);
|
|
}, remaining);
|
|
slot.idleTimer = handle;
|
|
const maybeTimer = handle as { unref?: () => void } | null;
|
|
maybeTimer?.unref?.();
|
|
};
|
|
|
|
const enforceGlobalIdleCap = () => {
|
|
const idleSlots: Array<{ poolKey: string; slot: PoolSlot }> = [];
|
|
for (const [poolKey, list] of pools.entries()) {
|
|
for (const slot of list) {
|
|
if (slot.holders.size === 0) idleSlots.push({ poolKey, slot });
|
|
}
|
|
}
|
|
idleSlots.sort((left, right) => {
|
|
if (left.slot.lastUsedAt !== right.slot.lastUsedAt) {
|
|
return left.slot.lastUsedAt - right.slot.lastUsedAt;
|
|
}
|
|
return (left.slot.idleOrder ?? 0) - (right.slot.idleOrder ?? 0);
|
|
});
|
|
for (let index = 0; index < idleSlots.length - maxIdleConnections; index += 1) {
|
|
const candidate = idleSlots[index]!;
|
|
const currentList = pools.get(candidate.poolKey);
|
|
const currentIdx = currentList?.indexOf(candidate.slot) ?? -1;
|
|
if (currentList && currentIdx >= 0 && candidate.slot.holders.size === 0) {
|
|
removeAndCloseSlot(candidate.poolKey, currentList, currentIdx);
|
|
}
|
|
}
|
|
};
|
|
|
|
const pickSlot = (list: PoolSlot[]): PoolSlot | null => {
|
|
// Never hand out sessions marked dead by a prior discard.
|
|
const healthy = list.filter((slot) => !slot.unhealthy);
|
|
if (healthy.length === 0) return null;
|
|
// Prefer idle connections, else least-loaded (FileZilla-style multiplexing).
|
|
const sorted = [...healthy].sort((a, b) => {
|
|
if (a.holders.size !== b.holders.size) return a.holders.size - b.holders.size;
|
|
return a.lastUsedAt - b.lastUsedAt;
|
|
});
|
|
return sorted[0] ?? null;
|
|
};
|
|
|
|
const release = (poolKey: string, sftpId: string, transferId: string) => {
|
|
const list = pools.get(poolKey);
|
|
if (!list) return;
|
|
const idx = list.findIndex((candidate) => candidate.sftpId === sftpId);
|
|
if (idx < 0) return;
|
|
const slot = list[idx]!;
|
|
if (!slot.holders.delete(transferId)) return;
|
|
slot.lastUsedAt = now();
|
|
if (slot.holders.size === 0) {
|
|
if (slot.unhealthy) {
|
|
removeAndCloseSlot(poolKey, list, idx);
|
|
return;
|
|
}
|
|
slot.idleSince = slot.lastUsedAt;
|
|
slot.idleOrder = nextIdleOrder;
|
|
nextIdleOrder += 1;
|
|
scheduleIdleClose(poolKey, slot, idleTtlMs);
|
|
enforceGlobalIdleCap();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Mark a session unusable for new work. Only closes the underlying session
|
|
* when no other holders remain — multiplexed siblings must not be killed.
|
|
*/
|
|
const discard = (sftpId: string, options?: { transferId?: string }) => {
|
|
if (!sftpId) return;
|
|
for (const [poolKey, list] of pools.entries()) {
|
|
const idx = list.findIndex((slot) => slot.sftpId === sftpId);
|
|
if (idx < 0) continue;
|
|
const slot = list[idx]!;
|
|
if (options?.transferId) slot.holders.delete(options.transferId);
|
|
slot.unhealthy = true;
|
|
slot.lastUsedAt = now();
|
|
if (slot.holders.size > 0) {
|
|
// Peers still using this socket; close when the last one releases.
|
|
return;
|
|
}
|
|
removeAndCloseSlot(poolKey, list, idx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
const makeLease = (poolKey: string, sftpId: string, transferId: string): TransferConnectionLease => ({
|
|
sftpId,
|
|
poolKey,
|
|
release: () => release(poolKey, sftpId, transferId),
|
|
discard: () => discard(sftpId, { transferId }),
|
|
});
|
|
|
|
const acquire = async (
|
|
poolKey: string,
|
|
transferId: string,
|
|
open: TransferPoolOpenFn,
|
|
): Promise<TransferConnectionLease> => {
|
|
if (!poolKey) throw new Error("Transfer pool key is required");
|
|
if (!transferId) throw new Error("Transfer id is required");
|
|
|
|
return withOpenLock(poolKey, async () => {
|
|
const list = getList(poolKey);
|
|
// Count only healthy slots toward the open budget; unhealthy ones drain
|
|
// as holders leave and should not block opening a replacement connection.
|
|
const healthyCount = list.filter((slot) => !slot.unhealthy).length;
|
|
const existing = pickSlot(list);
|
|
// Reuse when we already have max healthy connections, or when an idle one exists.
|
|
// FileZilla-style: open a second connection only when the first is busy.
|
|
// Idle slots are available during the short reuse window.
|
|
if (existing && (existing.holders.size === 0 || healthyCount >= maxPerHost)) {
|
|
clearIdleTimer(existing);
|
|
existing.idleSince = undefined;
|
|
existing.idleOrder = undefined;
|
|
existing.holders.add(transferId);
|
|
existing.lastUsedAt = now();
|
|
return makeLease(poolKey, existing.sftpId, transferId);
|
|
}
|
|
|
|
if (healthyCount < maxPerHost) {
|
|
let sftpId: string;
|
|
try {
|
|
sftpId = await open(poolKey);
|
|
} catch (error) {
|
|
// getList() installs the per-host array before opening. A failed
|
|
// connection must not leave one empty key behind forever in the
|
|
// process-wide pool (notably when many different hosts are tried).
|
|
if (list.length === 0 && pools.get(poolKey) === list) {
|
|
pools.delete(poolKey);
|
|
}
|
|
throw error;
|
|
}
|
|
const slot = await createRetainedSlot(sftpId, transferId);
|
|
// The last holder of the old list can release while open() is awaiting,
|
|
// which removes that empty list from `pools`. Re-read/recreate the
|
|
// canonical list before publishing the new slot; otherwise the lease is
|
|
// returned from a detached array and can never be released or closed.
|
|
getList(poolKey).push(slot);
|
|
return makeLease(poolKey, sftpId, transferId);
|
|
}
|
|
|
|
// Should be unreachable when maxPerHost >= 1, but keep safe fallback.
|
|
const fallback = pickSlot(list);
|
|
if (!fallback) {
|
|
const sftpId = await open(poolKey);
|
|
const slot = await createRetainedSlot(sftpId, transferId);
|
|
list.push(slot);
|
|
return makeLease(poolKey, sftpId, transferId);
|
|
}
|
|
fallback.holders.add(transferId);
|
|
clearIdleTimer(fallback);
|
|
fallback.idleSince = undefined;
|
|
fallback.idleOrder = undefined;
|
|
fallback.lastUsedAt = now();
|
|
return makeLease(poolKey, fallback.sftpId, transferId);
|
|
});
|
|
};
|
|
|
|
const closeIdle = async (sweepNow = now()): Promise<number> => {
|
|
const toClose: PoolSlot[] = [];
|
|
for (const [poolKey, list] of pools.entries()) {
|
|
const kept: PoolSlot[] = [];
|
|
for (const slot of list) {
|
|
const expired = slot.holders.size === 0
|
|
&& (slot.unhealthy || idleTtlMs <= 0 || (slot.idleSince ?? slot.lastUsedAt) + idleTtlMs <= sweepNow);
|
|
if (expired) {
|
|
clearIdleTimer(slot);
|
|
toClose.push(slot);
|
|
continue;
|
|
}
|
|
kept.push(slot);
|
|
}
|
|
if (kept.length === 0) pools.delete(poolKey);
|
|
else pools.set(poolKey, kept);
|
|
}
|
|
let closed = 0;
|
|
for (const slot of toClose) {
|
|
closed += 1;
|
|
await closeSlot(slot);
|
|
}
|
|
return closed;
|
|
};
|
|
|
|
const closeAll = async () => {
|
|
const toClose = [...pools.values()].flatMap((list) => list);
|
|
pools.clear();
|
|
for (const slot of toClose) clearIdleTimer(slot);
|
|
for (const slot of toClose) {
|
|
await closeSlot(slot);
|
|
}
|
|
};
|
|
|
|
const getStats = (poolKey?: string) => {
|
|
const lists = poolKey ? [pools.get(poolKey) ?? []] : [...pools.values()];
|
|
let connections = 0;
|
|
let busy = 0;
|
|
let idle = 0;
|
|
let holders = 0;
|
|
for (const list of lists) {
|
|
for (const slot of list) {
|
|
connections += 1;
|
|
holders += slot.holders.size;
|
|
if (slot.holders.size > 0) busy += 1;
|
|
else idle += 1;
|
|
}
|
|
}
|
|
const pendingOpenLocks = poolKey
|
|
? (openLocks.has(poolKey) ? 1 : 0)
|
|
: openLocks.size;
|
|
return { poolKeys: pools.size, connections, busy, idle, holders, pendingOpenLocks };
|
|
};
|
|
|
|
return {
|
|
acquire,
|
|
release,
|
|
discard,
|
|
getStats,
|
|
closeIdle,
|
|
closeAll,
|
|
setMaxPerHost(max: number) {
|
|
maxPerHost = normalizeMaxPerHost(max);
|
|
},
|
|
setIdleTtlMs(ms: number) {
|
|
idleTtlMs = normalizeIdleTtlMs(ms);
|
|
for (const [poolKey, list] of [...pools.entries()]) {
|
|
for (const slot of [...list]) {
|
|
if (slot.holders.size > 0) continue;
|
|
scheduleIdleClose(
|
|
poolKey,
|
|
slot,
|
|
Math.max(0, (slot.idleSince ?? slot.lastUsedAt) + idleTtlMs - now()),
|
|
);
|
|
}
|
|
}
|
|
enforceGlobalIdleCap();
|
|
},
|
|
getIdleTtlMs() {
|
|
return idleTtlMs;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Shared process-wide pool used by SFTP bulk transfers in the renderer. */
|
|
let sharedPool: TransferConnectionPool | null = null;
|
|
|
|
/**
|
|
* Process-wide transfer channel pool (FileZilla-style, max 2 sftpIds per host).
|
|
* `closeSession` is applied on first creation; later callers share the same pool.
|
|
*/
|
|
export function getSharedTransferConnectionPool(
|
|
options?: TransferConnectionPoolOptions,
|
|
): TransferConnectionPool {
|
|
if (!sharedPool) {
|
|
sharedPool = createTransferConnectionPool({
|
|
maxPerHost: DEFAULT_TRANSFER_CONNECTIONS_PER_HOST,
|
|
...options,
|
|
});
|
|
}
|
|
return sharedPool;
|
|
}
|
|
|
|
/** Test-only: drop the singleton so tests start clean. */
|
|
export function resetSharedTransferConnectionPoolForTests(): void {
|
|
sharedPool = null;
|
|
}
|
|
|
|
export async function buildTransferPoolKey(input: TransferPoolKeyInput): Promise<string> {
|
|
const stableSerialize = (value: unknown): string => {
|
|
if (value === undefined) return '"__undefined__"';
|
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.filter(([key]) => key !== "sessionId")
|
|
.sort(([left], [right]) => left.localeCompare(right));
|
|
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`).join(",")}}`;
|
|
};
|
|
// Include endpoint identity whenever hostname is known so session-time
|
|
// hostname/port/username overrides do not share a pool with the vault host.
|
|
if (input.hostname) {
|
|
const port = input.port || 22;
|
|
const user = input.username || "root";
|
|
const protocol = input.protocol || "ssh";
|
|
const sudo = input.sftpSudo ? "sudo" : "nosudo";
|
|
const ep = `${input.hostname}:${port}:${user}:${protocol}:${sudo}`;
|
|
const base = input.hostId ? `host:${input.hostId}|ep:${ep}` : `ep:${ep}`;
|
|
if (!input.connectionOptions) return base;
|
|
const encoded = new TextEncoder().encode(stableSerialize(input.connectionOptions));
|
|
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
|
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
return `${base}|identity:${fingerprint}`;
|
|
}
|
|
if (input.hostId) return `host:${input.hostId}`;
|
|
return "ep:unknown:22:root:ssh:nosudo";
|
|
}
|