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
552 lines
18 KiB
TypeScript
552 lines
18 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
buildTransferPoolKey,
|
|
createTransferConnectionPool,
|
|
createTransferPoolKeyCache,
|
|
DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS,
|
|
DEFAULT_TRANSFER_CONNECTIONS_PER_HOST,
|
|
DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS,
|
|
} from "./transferConnectionPool.ts";
|
|
|
|
function createFakeClock() {
|
|
let current = 0;
|
|
let nextId = 1;
|
|
const timers = new Map<number, { callback: () => void; deadline: number }>();
|
|
return {
|
|
now: () => current,
|
|
setTimeoutFn(callback: () => void, delayMs: number) {
|
|
const id = nextId;
|
|
nextId += 1;
|
|
timers.set(id, { callback, deadline: current + Math.max(0, delayMs) });
|
|
return id;
|
|
},
|
|
clearTimeoutFn(id: unknown) {
|
|
timers.delete(id as number);
|
|
},
|
|
advance(ms: number) {
|
|
current += ms;
|
|
for (;;) {
|
|
const due = [...timers.entries()]
|
|
.filter(([, timer]) => timer.deadline <= current)
|
|
.sort((left, right) => left[1].deadline - right[1].deadline)[0];
|
|
if (!due) break;
|
|
timers.delete(due[0]);
|
|
due[1].callback();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
test("transfer pool key cache resolves a host identity only once and retries failures", async () => {
|
|
let builds = 0;
|
|
let inputBuilds = 0;
|
|
let failFirst = true;
|
|
const cache = createTransferPoolKeyCache(async (input) => {
|
|
builds += 1;
|
|
if (failFirst) {
|
|
failFirst = false;
|
|
throw new Error("temporary identity failure");
|
|
}
|
|
return `key:${input.hostId}`;
|
|
});
|
|
const host = {};
|
|
const createInput = () => {
|
|
inputBuilds += 1;
|
|
return { hostId: "h1" };
|
|
};
|
|
|
|
await assert.rejects(cache.get(host, createInput), /temporary identity failure/);
|
|
assert.equal(await cache.get(host, createInput), "key:h1");
|
|
assert.equal(await cache.get(host, createInput), "key:h1");
|
|
assert.equal(builds, 2, "a rejected identity must be evicted and rebuilt once");
|
|
assert.equal(inputBuilds, 2, "cached calls must skip credential construction too");
|
|
|
|
assert.equal(await cache.get({}, createInput), "key:h1");
|
|
assert.equal(builds, 3, "different host objects must not share identity promises");
|
|
});
|
|
|
|
test("buildTransferPoolKey includes endpoint when hostname is known", async () => {
|
|
assert.equal(
|
|
await buildTransferPoolKey({ hostId: "h1", hostname: "vault.example", port: 22, username: "root" }),
|
|
"host:h1|ep:vault.example:22:root:ssh:nosudo",
|
|
);
|
|
// Same hostId with session override must not share the vault pool key.
|
|
assert.equal(
|
|
await buildTransferPoolKey({ hostId: "h1", hostname: "override.example", port: 2222, username: "ubuntu" }),
|
|
"host:h1|ep:override.example:2222:ubuntu:ssh:nosudo",
|
|
);
|
|
assert.equal(
|
|
await buildTransferPoolKey({ hostname: "ci.example", port: 22, username: "root" }),
|
|
"ep:ci.example:22:root:ssh:nosudo",
|
|
);
|
|
assert.equal(await buildTransferPoolKey({ hostId: "h1" }), "host:h1");
|
|
});
|
|
|
|
test("buildTransferPoolKey separates every transport security identity", async () => {
|
|
const connectionOptions = {
|
|
hostname: "target.example",
|
|
port: 22,
|
|
username: "root",
|
|
password: "secret-a",
|
|
keepaliveInterval: 30,
|
|
algorithmOverrides: { kex: ["curve25519-sha256"] },
|
|
jumpHosts: [{ hostname: "jump-a", port: 22, username: "jump" }],
|
|
verifyHostKeys: true,
|
|
knownHosts: [{
|
|
id: "known-a",
|
|
hostname: "target.example",
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: "AAAA",
|
|
fingerprint: "a",
|
|
discoveredAt: 0,
|
|
}],
|
|
} as unknown as NetcattySSHOptions;
|
|
const base = {
|
|
hostId: "h1",
|
|
hostname: "target.example",
|
|
port: 22,
|
|
username: "root",
|
|
connectionOptions,
|
|
};
|
|
const key = await buildTransferPoolKey(base);
|
|
const variant = (overrides: Partial<NetcattySSHOptions>): NetcattySSHOptions => ({
|
|
...connectionOptions,
|
|
...overrides,
|
|
});
|
|
const variants: NetcattySSHOptions[] = [
|
|
variant({ password: "secret-b" }),
|
|
variant({ keepaliveInterval: 60 }),
|
|
variant({ algorithmOverrides: { kex: ["diffie-hellman-group14-sha256"] } }),
|
|
variant({ jumpHosts: [{ hostname: "jump-b", port: 22, username: "jump" }] }),
|
|
variant({ verifyHostKeys: false }),
|
|
variant({ knownHosts: [{
|
|
id: "known-b",
|
|
hostname: "target.example",
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: "BBBB",
|
|
fingerprint: "b",
|
|
discoveredAt: 0,
|
|
}] }),
|
|
];
|
|
for (const connectionOptions of variants) {
|
|
assert.notEqual(await buildTransferPoolKey({ ...base, connectionOptions }), key);
|
|
}
|
|
});
|
|
|
|
test("pool opens at most maxPerHost channels and multiplexes when busy", async () => {
|
|
let opens = 0;
|
|
const closed: string[] = [];
|
|
const pool = createTransferConnectionPool({
|
|
maxPerHost: 2,
|
|
closeSession: async (id) => { closed.push(id); },
|
|
});
|
|
|
|
const open = async () => {
|
|
opens += 1;
|
|
return `sftp-${opens}`;
|
|
};
|
|
|
|
const a = await pool.acquire("host:a", "t1", open);
|
|
const b = await pool.acquire("host:a", "t2", open);
|
|
assert.equal(opens, 2);
|
|
assert.notEqual(a.sftpId, b.sftpId);
|
|
|
|
// Third transfer reuses least-loaded connection (both size 1 → first by age).
|
|
const c = await pool.acquire("host:a", "t3", open);
|
|
assert.equal(opens, 2);
|
|
assert.ok(c.sftpId === a.sftpId || c.sftpId === b.sftpId);
|
|
|
|
a.release();
|
|
b.release();
|
|
c.release();
|
|
|
|
assert.equal(closed.length, 0);
|
|
assert.equal(pool.getStats("host:a").connections, 2);
|
|
assert.equal(pool.getStats("host:a").idle, 2);
|
|
|
|
const d = await pool.acquire("host:a", "t4", open);
|
|
assert.equal(opens, 2, "the next small file must reuse a short-idle channel");
|
|
d.release();
|
|
await pool.closeAll();
|
|
assert.equal(closed.length, 2);
|
|
});
|
|
|
|
test("different hosts get independent channel pools", async () => {
|
|
let opens = 0;
|
|
const pool = createTransferConnectionPool({ maxPerHost: 1 });
|
|
const open = async () => {
|
|
opens += 1;
|
|
return `sftp-${opens}`;
|
|
};
|
|
|
|
const a = await pool.acquire("host:a", "t1", open);
|
|
const b = await pool.acquire("host:b", "t2", open);
|
|
assert.equal(opens, 2);
|
|
assert.notEqual(a.sftpId, b.sftpId);
|
|
a.release();
|
|
b.release();
|
|
});
|
|
|
|
test("failed opens do not retain empty host pool keys", async () => {
|
|
const pool = createTransferConnectionPool({ maxPerHost: 1 });
|
|
|
|
for (let index = 0; index < 100; index += 1) {
|
|
await assert.rejects(
|
|
pool.acquire(`host:failed-${index}`, `transfer-${index}`, async () => {
|
|
throw new Error("connection failed");
|
|
}),
|
|
/connection failed/,
|
|
);
|
|
}
|
|
|
|
assert.equal(pool.getStats().poolKeys, 0);
|
|
assert.equal(pool.getStats().pendingOpenLocks, 0);
|
|
});
|
|
|
|
test("sequential small files reuse one channel until the short idle TTL expires", async () => {
|
|
const clock = createFakeClock();
|
|
const closed: string[] = [];
|
|
let opens = 0;
|
|
const pool = createTransferConnectionPool({
|
|
maxPerHost: 1,
|
|
idleTtlMs: 5_000,
|
|
closeSession: async (id) => { closed.push(id); },
|
|
now: clock.now,
|
|
setTimeoutFn: clock.setTimeoutFn,
|
|
clearTimeoutFn: clock.clearTimeoutFn,
|
|
});
|
|
const open = async () => `sftp-${++opens}`;
|
|
|
|
const first = await pool.acquire("host:x", "file-1", open);
|
|
first.release();
|
|
assert.equal(pool.getStats("host:x").idle, 1);
|
|
assert.deepEqual(closed, []);
|
|
|
|
for (let index = 2; index <= 100; index += 1) {
|
|
const next = await pool.acquire("host:x", `file-${index}`, open);
|
|
assert.equal(next.sftpId, first.sftpId);
|
|
next.release();
|
|
}
|
|
assert.equal(opens, 1);
|
|
|
|
clock.advance(4_999);
|
|
assert.deepEqual(closed, []);
|
|
clock.advance(1);
|
|
assert.deepEqual(closed, ["sftp-1"]);
|
|
assert.equal(pool.getStats("host:x").connections, 0);
|
|
});
|
|
|
|
test("a pool slot retains its SFTP session for the whole directory walk", async () => {
|
|
const clock = createFakeClock();
|
|
const retained: string[] = [];
|
|
const released: string[] = [];
|
|
const closed: string[] = [];
|
|
const pool = createTransferConnectionPool({
|
|
maxPerHost: 1,
|
|
idleTtlMs: 5_000,
|
|
now: clock.now,
|
|
setTimeoutFn: clock.setTimeoutFn,
|
|
clearTimeoutFn: clock.clearTimeoutFn,
|
|
retainSession: async (sftpId, leaseId) => { retained.push(`${sftpId}:${leaseId}`); },
|
|
releaseSession: async (sftpId, leaseId) => { released.push(`${sftpId}:${leaseId}`); },
|
|
closeSession: async (sftpId) => { closed.push(sftpId); },
|
|
});
|
|
|
|
const root = await pool.acquire("host:folder", "directory-root", async () => "sftp-folder");
|
|
const child = await pool.acquire("host:folder", "directory-child", async () => {
|
|
throw new Error("must reuse the retained directory session");
|
|
});
|
|
|
|
assert.deepEqual(retained, ["sftp-folder:pool:sftp-folder"]);
|
|
child.release();
|
|
assert.deepEqual(released, [], "finishing one child must not release the pool session");
|
|
root.release();
|
|
assert.deepEqual(released, [], "the idle reuse window still owns the session");
|
|
|
|
clock.advance(5_000);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(released, ["sftp-folder:pool:sftp-folder"]);
|
|
assert.deepEqual(closed, ["sftp-folder"]);
|
|
});
|
|
|
|
test("default max per host is FileZilla-like (2)", () => {
|
|
assert.equal(DEFAULT_TRANSFER_CONNECTIONS_PER_HOST, 2);
|
|
assert.equal(DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS, 5_000);
|
|
assert.equal(DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS, 16);
|
|
});
|
|
|
|
test("global idle cap evicts the oldest host channel", async () => {
|
|
const clock = createFakeClock();
|
|
const closed: string[] = [];
|
|
let opens = 0;
|
|
const pool = createTransferConnectionPool({
|
|
maxPerHost: 1,
|
|
maxIdleConnections: 2,
|
|
idleTtlMs: 60_000,
|
|
closeSession: async (id) => { closed.push(id); },
|
|
now: clock.now,
|
|
setTimeoutFn: clock.setTimeoutFn,
|
|
clearTimeoutFn: clock.clearTimeoutFn,
|
|
});
|
|
const open = async () => `sftp-${++opens}`;
|
|
|
|
for (const [index, hostKey] of ["host:a", "host:b", "host:c"].entries()) {
|
|
const lease = await pool.acquire(hostKey, `file-${index}`, open);
|
|
lease.release();
|
|
clock.advance(1);
|
|
}
|
|
|
|
assert.deepEqual(closed, ["sftp-1"]);
|
|
assert.equal(pool.getStats().connections, 2);
|
|
assert.equal(pool.getStats().idle, 2);
|
|
assert.equal(pool.getStats().poolKeys, 2);
|
|
});
|
|
|
|
test("concurrent acquires do not exceed maxPerHost", async () => {
|
|
let opens = 0;
|
|
let inFlightOpens = 0;
|
|
let maxInFlightOpens = 0;
|
|
const pool = createTransferConnectionPool({ maxPerHost: 2 });
|
|
const open = async () => {
|
|
inFlightOpens += 1;
|
|
maxInFlightOpens = Math.max(maxInFlightOpens, inFlightOpens);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
opens += 1;
|
|
inFlightOpens -= 1;
|
|
return `sftp-${opens}`;
|
|
};
|
|
|
|
const leases = await Promise.all([
|
|
pool.acquire("host:a", "t1", open),
|
|
pool.acquire("host:a", "t2", open),
|
|
pool.acquire("host:a", "t3", open),
|
|
pool.acquire("host:a", "t4", open),
|
|
]);
|
|
|
|
assert.equal(opens, 2);
|
|
assert.ok(maxInFlightOpens <= 2);
|
|
const ids = new Set(leases.map((l) => l.sftpId));
|
|
assert.equal(ids.size, 2);
|
|
for (const lease of leases) lease.release();
|
|
});
|
|
|
|
test("per-host open serialization state survives queued work and is released after the last waiter", async () => {
|
|
let markFirstStarted!: () => void;
|
|
let markSecondStarted!: () => void;
|
|
let finishFirst!: () => void;
|
|
let finishSecond!: () => void;
|
|
const firstStarted = new Promise<void>((resolve) => { markFirstStarted = resolve; });
|
|
const secondStarted = new Promise<void>((resolve) => { markSecondStarted = resolve; });
|
|
const firstFinished = new Promise<void>((resolve) => { finishFirst = resolve; });
|
|
const secondFinished = new Promise<void>((resolve) => { finishSecond = resolve; });
|
|
const pool = createTransferConnectionPool({ maxPerHost: 2 });
|
|
let opens = 0;
|
|
const open = async () => {
|
|
opens += 1;
|
|
if (opens === 1) {
|
|
markFirstStarted();
|
|
await firstFinished;
|
|
} else {
|
|
markSecondStarted();
|
|
await secondFinished;
|
|
}
|
|
return `sftp-ephemeral-${opens}`;
|
|
};
|
|
|
|
const firstAcquiring = pool.acquire("host:ephemeral", "t1", open);
|
|
await firstStarted;
|
|
const secondAcquiring = pool.acquire("host:ephemeral", "t2", open);
|
|
assert.equal(pool.getStats("host:ephemeral").pendingOpenLocks, 1);
|
|
|
|
finishFirst();
|
|
const firstLease = await firstAcquiring;
|
|
await secondStarted;
|
|
assert.equal(
|
|
pool.getStats("host:ephemeral").pendingOpenLocks,
|
|
1,
|
|
"the first opener must not clear a later waiter's serialization state",
|
|
);
|
|
|
|
finishSecond();
|
|
const secondLease = await secondAcquiring;
|
|
assert.equal(pool.getStats("host:ephemeral").pendingOpenLocks, 0);
|
|
firstLease.release();
|
|
secondLease.release();
|
|
});
|
|
|
|
test("busy first channel causes a second open (FileZilla style)", async () => {
|
|
let opens = 0;
|
|
const pool = createTransferConnectionPool({ maxPerHost: 2 });
|
|
const open = async () => {
|
|
opens += 1;
|
|
return `sftp-${opens}`;
|
|
};
|
|
|
|
const first = await pool.acquire("host:a", "t1", open);
|
|
assert.equal(opens, 1);
|
|
|
|
// First is still held → open a second dedicated channel.
|
|
const second = await pool.acquire("host:a", "t2", open);
|
|
assert.equal(opens, 2);
|
|
assert.notEqual(first.sftpId, second.sftpId);
|
|
|
|
first.release();
|
|
second.release();
|
|
});
|
|
|
|
test("a replacement opened while the last old holder releases stays tracked and closable", async () => {
|
|
const closed: string[] = [];
|
|
const pool = createTransferConnectionPool({
|
|
maxPerHost: 2,
|
|
idleTtlMs: 0,
|
|
closeSession: async (id) => { closed.push(id); },
|
|
});
|
|
const first = await pool.acquire("host:release-during-open", "t1", async () => "sftp-old");
|
|
|
|
let finishReplacementOpen!: () => void;
|
|
const replacementOpenGate = new Promise<void>((resolve) => {
|
|
finishReplacementOpen = resolve;
|
|
});
|
|
let replacementOpenStarted!: () => void;
|
|
const replacementStarted = new Promise<void>((resolve) => {
|
|
replacementOpenStarted = resolve;
|
|
});
|
|
const replacementPromise = pool.acquire(
|
|
"host:release-during-open",
|
|
"t2",
|
|
async () => {
|
|
replacementOpenStarted();
|
|
await replacementOpenGate;
|
|
return "sftp-new";
|
|
},
|
|
);
|
|
await replacementStarted;
|
|
|
|
first.release();
|
|
finishReplacementOpen();
|
|
const replacement = await replacementPromise;
|
|
|
|
assert.deepEqual(closed, ["sftp-old"]);
|
|
assert.equal(pool.getStats("host:release-during-open").connections, 1);
|
|
assert.equal(pool.getStats("host:release-during-open").holders, 1);
|
|
|
|
replacement.release();
|
|
assert.deepEqual(closed, ["sftp-old", "sftp-new"]);
|
|
assert.equal(pool.getStats().connections, 0);
|
|
assert.equal(pool.getStats().poolKeys, 0);
|
|
});
|
|
|
|
test("discard removes a dead session so next acquire reopens", async () => {
|
|
let opens = 0;
|
|
const closed: string[] = [];
|
|
const pool = createTransferConnectionPool({
|
|
maxPerHost: 1,
|
|
closeSession: async (id) => { closed.push(id); },
|
|
});
|
|
const open = async () => {
|
|
opens += 1;
|
|
return `sftp-${opens}`;
|
|
};
|
|
|
|
const a = await pool.acquire("host:a", "t1", open);
|
|
assert.equal(opens, 1);
|
|
a.discard();
|
|
assert.equal(closed.length, 1);
|
|
assert.equal(pool.getStats("host:a").connections, 0);
|
|
|
|
const b = await pool.acquire("host:a", "t2", open);
|
|
assert.equal(opens, 2);
|
|
assert.notEqual(a.sftpId, b.sftpId);
|
|
b.release();
|
|
});
|
|
|
|
test("cancellation returns a healthy channel, while a session error discards it", async () => {
|
|
const clock = createFakeClock();
|
|
const closed: string[] = [];
|
|
let opens = 0;
|
|
const pool = createTransferConnectionPool({
|
|
maxPerHost: 1,
|
|
idleTtlMs: 5_000,
|
|
closeSession: async (id) => { closed.push(id); },
|
|
now: clock.now,
|
|
setTimeoutFn: clock.setTimeoutFn,
|
|
clearTimeoutFn: clock.clearTimeoutFn,
|
|
});
|
|
const open = async () => `sftp-${++opens}`;
|
|
|
|
const cancelled = await pool.acquire("host:a", "cancelled-transfer", open);
|
|
cancelled.release();
|
|
assert.equal(pool.getStats("host:a").idle, 1);
|
|
assert.deepEqual(closed, []);
|
|
|
|
const next = await pool.acquire("host:a", "next-transfer", open);
|
|
assert.equal(next.sftpId, cancelled.sftpId);
|
|
assert.equal(opens, 1);
|
|
next.discard();
|
|
assert.deepEqual(closed, ["sftp-1"]);
|
|
assert.equal(pool.getStats("host:a").connections, 0);
|
|
|
|
const recovered = await pool.acquire("host:a", "recovered-transfer", open);
|
|
assert.equal(recovered.sftpId, "sftp-2");
|
|
recovered.release();
|
|
clock.advance(5_000);
|
|
assert.deepEqual(closed, ["sftp-1", "sftp-2"]);
|
|
});
|
|
|
|
test("closeIdle detaches only expired idle slots before awaiting close", async () => {
|
|
let opens = 0;
|
|
let closeStarted = 0;
|
|
let now = 0;
|
|
let releaseClose!: () => void;
|
|
const closeGate = new Promise<void>((resolve) => { releaseClose = resolve; });
|
|
const pool = createTransferConnectionPool({
|
|
maxPerHost: 1,
|
|
idleTtlMs: 100,
|
|
now: () => now,
|
|
closeSession: async () => {
|
|
closeStarted += 1;
|
|
await closeGate;
|
|
},
|
|
});
|
|
const open = async () => {
|
|
opens += 1;
|
|
return `sftp-${opens}`;
|
|
};
|
|
|
|
const a = await pool.acquire("host:a", "t1", open);
|
|
assert.equal(opens, 1);
|
|
a.release();
|
|
assert.equal(await pool.closeIdle(now), 0);
|
|
now = 100;
|
|
const closing = pool.closeIdle(now);
|
|
assert.equal(closeStarted, 1);
|
|
assert.equal(pool.getStats().connections, 0, "expired slot must detach before close awaits");
|
|
releaseClose();
|
|
assert.equal(await closing, 1);
|
|
});
|
|
|
|
test("setIdleTtlMs reschedules parked channels and zero closes them immediately", async () => {
|
|
const clock = createFakeClock();
|
|
const closed: string[] = [];
|
|
const pool = createTransferConnectionPool({
|
|
idleTtlMs: 60_000,
|
|
closeSession: async (id) => { closed.push(id); },
|
|
now: clock.now,
|
|
setTimeoutFn: clock.setTimeoutFn,
|
|
clearTimeoutFn: clock.clearTimeoutFn,
|
|
});
|
|
const lease = await pool.acquire("host:a", "t1", async () => "sftp-a");
|
|
lease.release();
|
|
pool.setIdleTtlMs(5_000);
|
|
assert.equal(pool.getIdleTtlMs(), 5_000);
|
|
clock.advance(4_999);
|
|
assert.deepEqual(closed, []);
|
|
pool.setIdleTtlMs(0);
|
|
assert.deepEqual(closed, ["sftp-a"]);
|
|
assert.equal(pool.getStats().connections, 0);
|
|
});
|