agent-pool.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865
  1. /**
  2. * Agent Pool Tests
  3. *
  4. * TDD: Tests written first, implementation follows
  5. */
  6. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  7. // Mock undici before importing agent-pool
  8. vi.mock("undici", () => ({
  9. Agent: vi.fn().mockImplementation((options) => ({
  10. options,
  11. close: vi.fn().mockResolvedValue(undefined),
  12. destroy: vi.fn().mockResolvedValue(undefined),
  13. })),
  14. ProxyAgent: vi.fn().mockImplementation((options) => ({
  15. options,
  16. close: vi.fn().mockResolvedValue(undefined),
  17. destroy: vi.fn().mockResolvedValue(undefined),
  18. })),
  19. }));
  20. vi.mock("fetch-socks", () => ({
  21. socksDispatcher: vi.fn().mockImplementation((proxy, options) => ({
  22. proxy,
  23. options,
  24. close: vi.fn().mockResolvedValue(undefined),
  25. destroy: vi.fn().mockResolvedValue(undefined),
  26. })),
  27. }));
  28. import {
  29. type AgentPool,
  30. AgentPoolImpl,
  31. generateAgentCacheKey,
  32. getGlobalAgentPool,
  33. resetGlobalAgentPool,
  34. type AgentPoolConfig,
  35. } from "@/lib/proxy-agent/agent-pool";
  36. describe("generateAgentCacheKey", () => {
  37. it("should generate correct cache key for direct connection", () => {
  38. const key = generateAgentCacheKey({
  39. endpointUrl: "https://api.anthropic.com/v1/messages",
  40. proxyUrl: null,
  41. enableHttp2: false,
  42. });
  43. expect(key).toBe("https://api.anthropic.com|direct|h1");
  44. });
  45. it("should generate correct cache key with proxy", () => {
  46. const key = generateAgentCacheKey({
  47. endpointUrl: "https://api.openai.com/v1/chat/completions",
  48. proxyUrl: "http://proxy.example.com:8080",
  49. enableHttp2: false,
  50. });
  51. expect(key).toBe("https://api.openai.com|http://proxy.example.com:8080|h1");
  52. });
  53. it("should generate correct cache key with HTTP/2 enabled", () => {
  54. const key = generateAgentCacheKey({
  55. endpointUrl: "https://api.anthropic.com/v1/messages",
  56. proxyUrl: null,
  57. enableHttp2: true,
  58. });
  59. expect(key).toBe("https://api.anthropic.com|direct|h2");
  60. });
  61. it("should generate correct cache key with proxy and HTTP/2", () => {
  62. const key = generateAgentCacheKey({
  63. endpointUrl: "https://api.anthropic.com/v1/messages",
  64. proxyUrl: "https://secure-proxy.example.com:443",
  65. enableHttp2: true,
  66. });
  67. // URL API strips default port 443 for HTTPS
  68. expect(key).toBe("https://api.anthropic.com|https://secure-proxy.example.com|h2");
  69. });
  70. it("should use origin only (strip path and query)", () => {
  71. const key = generateAgentCacheKey({
  72. endpointUrl: "https://api.anthropic.com/v1/messages?key=value",
  73. proxyUrl: null,
  74. enableHttp2: false,
  75. });
  76. expect(key).toBe("https://api.anthropic.com|direct|h1");
  77. });
  78. it("should handle different ports", () => {
  79. const key = generateAgentCacheKey({
  80. endpointUrl: "https://api.example.com:8443/v1/messages",
  81. proxyUrl: null,
  82. enableHttp2: false,
  83. });
  84. expect(key).toBe("https://api.example.com:8443|direct|h1");
  85. });
  86. it("should differentiate HTTP and HTTPS", () => {
  87. const httpKey = generateAgentCacheKey({
  88. endpointUrl: "http://api.example.com/v1/messages",
  89. proxyUrl: null,
  90. enableHttp2: false,
  91. });
  92. const httpsKey = generateAgentCacheKey({
  93. endpointUrl: "https://api.example.com/v1/messages",
  94. proxyUrl: null,
  95. enableHttp2: false,
  96. });
  97. expect(httpKey).not.toBe(httpsKey);
  98. expect(httpKey).toBe("http://api.example.com|direct|h1");
  99. expect(httpsKey).toBe("https://api.example.com|direct|h1");
  100. });
  101. });
  102. describe("AgentPool", () => {
  103. let pool: AgentPool;
  104. const defaultConfig: AgentPoolConfig = {
  105. maxTotalAgents: 10,
  106. agentTtlMs: 300000, // 5 minutes
  107. connectionIdleTimeoutMs: 60000, // 1 minute
  108. cleanupIntervalMs: 30000, // 30 seconds
  109. };
  110. beforeEach(() => {
  111. vi.useFakeTimers();
  112. pool = new AgentPoolImpl(defaultConfig);
  113. });
  114. afterEach(async () => {
  115. await pool.shutdown();
  116. vi.useRealTimers();
  117. vi.clearAllMocks();
  118. });
  119. describe("caching behavior", () => {
  120. it("should reuse Agent for same endpoint", async () => {
  121. const params = {
  122. endpointUrl: "https://api.anthropic.com/v1/messages",
  123. proxyUrl: null,
  124. enableHttp2: false,
  125. };
  126. const result1 = await pool.getAgent(params);
  127. const result2 = await pool.getAgent(params);
  128. expect(result1.cacheKey).toBe(result2.cacheKey);
  129. expect(result1.agent).toBe(result2.agent);
  130. expect(result1.isNew).toBe(true);
  131. expect(result2.isNew).toBe(false);
  132. });
  133. it("should create different Agent for different endpoints", async () => {
  134. const result1 = await pool.getAgent({
  135. endpointUrl: "https://api.anthropic.com/v1/messages",
  136. proxyUrl: null,
  137. enableHttp2: false,
  138. });
  139. const result2 = await pool.getAgent({
  140. endpointUrl: "https://api.openai.com/v1/chat/completions",
  141. proxyUrl: null,
  142. enableHttp2: false,
  143. });
  144. expect(result1.cacheKey).not.toBe(result2.cacheKey);
  145. expect(result1.agent).not.toBe(result2.agent);
  146. expect(result1.isNew).toBe(true);
  147. expect(result2.isNew).toBe(true);
  148. });
  149. it("should create different Agent for different proxy configs", async () => {
  150. const result1 = await pool.getAgent({
  151. endpointUrl: "https://api.anthropic.com/v1/messages",
  152. proxyUrl: null,
  153. enableHttp2: false,
  154. });
  155. const result2 = await pool.getAgent({
  156. endpointUrl: "https://api.anthropic.com/v1/messages",
  157. proxyUrl: "http://proxy.example.com:8080",
  158. enableHttp2: false,
  159. });
  160. expect(result1.cacheKey).not.toBe(result2.cacheKey);
  161. expect(result1.agent).not.toBe(result2.agent);
  162. });
  163. it("should create different Agent for HTTP/2 vs HTTP/1.1", async () => {
  164. const result1 = await pool.getAgent({
  165. endpointUrl: "https://api.anthropic.com/v1/messages",
  166. proxyUrl: null,
  167. enableHttp2: false,
  168. });
  169. const result2 = await pool.getAgent({
  170. endpointUrl: "https://api.anthropic.com/v1/messages",
  171. proxyUrl: null,
  172. enableHttp2: true,
  173. });
  174. expect(result1.cacheKey).not.toBe(result2.cacheKey);
  175. expect(result1.agent).not.toBe(result2.agent);
  176. });
  177. it("should track request count", async () => {
  178. const params = {
  179. endpointUrl: "https://api.anthropic.com/v1/messages",
  180. proxyUrl: null,
  181. enableHttp2: false,
  182. };
  183. await pool.getAgent(params);
  184. await pool.getAgent(params);
  185. await pool.getAgent(params);
  186. const stats = pool.getPoolStats();
  187. expect(stats.totalRequests).toBe(3);
  188. expect(stats.cacheHits).toBe(2);
  189. expect(stats.cacheMisses).toBe(1);
  190. });
  191. });
  192. describe("health management", () => {
  193. it("should create new Agent after marking unhealthy", async () => {
  194. const params = {
  195. endpointUrl: "https://api.anthropic.com/v1/messages",
  196. proxyUrl: null,
  197. enableHttp2: false,
  198. };
  199. const result1 = await pool.getAgent(params);
  200. pool.markUnhealthy(result1.cacheKey, "SSL certificate error");
  201. const result2 = await pool.getAgent(params);
  202. expect(result2.isNew).toBe(true);
  203. expect(result2.agent).not.toBe(result1.agent);
  204. });
  205. it("should not hang when evicting an unhealthy agent whose close() never resolves", async () => {
  206. // 说明:beforeEach 使用了 fake timers,但此用例需要依赖真实 setTimeout 做“防卡死”断言
  207. await pool.shutdown();
  208. vi.useRealTimers();
  209. const realPool = new AgentPoolImpl(defaultConfig);
  210. const withTimeout = async <T>(promise: Promise<T>, ms: number): Promise<T> => {
  211. let timeoutId: ReturnType<typeof setTimeout> | null = null;
  212. try {
  213. return await Promise.race([
  214. promise,
  215. new Promise<T>((_, reject) => {
  216. timeoutId = setTimeout(() => reject(new Error("timeout")), ms);
  217. }),
  218. ]);
  219. } finally {
  220. if (timeoutId) clearTimeout(timeoutId);
  221. }
  222. };
  223. try {
  224. const params = {
  225. endpointUrl: "https://api.anthropic.com/v1/messages",
  226. proxyUrl: null,
  227. enableHttp2: true,
  228. };
  229. const result1 = await realPool.getAgent(params);
  230. const agent1 = result1.agent as unknown as {
  231. close?: () => Promise<void>;
  232. destroy?: unknown;
  233. };
  234. // 强制走 close() 分支:模拟某些 dispatcher 不支持 destroy()
  235. agent1.destroy = undefined;
  236. // 模拟:close 可能因等待 in-flight 请求结束而长期不返回
  237. let closeCalled = false;
  238. agent1.close = () => {
  239. closeCalled = true;
  240. return new Promise<void>(() => {});
  241. };
  242. realPool.markUnhealthy(result1.cacheKey, "test-hang-close");
  243. const result2 = await withTimeout(realPool.getAgent(params), 500);
  244. expect(result2.isNew).toBe(true);
  245. expect(result2.agent).not.toBe(result1.agent);
  246. // 断言:即使 close() 处于 pending,也不会阻塞 getAgent(),且会触发 close 调用
  247. expect(closeCalled).toBe(true);
  248. } finally {
  249. await realPool.shutdown();
  250. vi.useFakeTimers();
  251. }
  252. });
  253. it("should track unhealthy agents in stats", async () => {
  254. const params = {
  255. endpointUrl: "https://api.anthropic.com/v1/messages",
  256. proxyUrl: null,
  257. enableHttp2: false,
  258. };
  259. const result = await pool.getAgent(params);
  260. pool.markUnhealthy(result.cacheKey, "SSL certificate error");
  261. const stats = pool.getPoolStats();
  262. expect(stats.unhealthyAgents).toBe(1);
  263. });
  264. it("should evict all Agents for endpoint on evictEndpoint", async () => {
  265. // Create agents for same endpoint with different configs
  266. await pool.getAgent({
  267. endpointUrl: "https://api.anthropic.com/v1/messages",
  268. proxyUrl: null,
  269. enableHttp2: false,
  270. });
  271. await pool.getAgent({
  272. endpointUrl: "https://api.anthropic.com/v1/messages",
  273. proxyUrl: null,
  274. enableHttp2: true,
  275. });
  276. await pool.getAgent({
  277. endpointUrl: "https://api.openai.com/v1/chat/completions",
  278. proxyUrl: null,
  279. enableHttp2: false,
  280. });
  281. const statsBefore = pool.getPoolStats();
  282. expect(statsBefore.cacheSize).toBe(3);
  283. await pool.evictEndpoint("https://api.anthropic.com");
  284. const statsAfter = pool.getPoolStats();
  285. expect(statsAfter.cacheSize).toBe(1);
  286. expect(statsAfter.evictedAgents).toBe(2);
  287. });
  288. });
  289. describe("expiration cleanup", () => {
  290. it("should cleanup expired Agents", async () => {
  291. const shortTtlPool = new AgentPoolImpl({
  292. ...defaultConfig,
  293. agentTtlMs: 1000, // 1 second TTL
  294. });
  295. const { cacheKey, dispatcherId } = await shortTtlPool.getAgent({
  296. endpointUrl: "https://api.anthropic.com/v1/messages",
  297. proxyUrl: null,
  298. enableHttp2: false,
  299. });
  300. expect(shortTtlPool.getPoolStats().cacheSize).toBe(1);
  301. // Release the agent (simulates request completion)
  302. shortTtlPool.releaseAgent(cacheKey, dispatcherId);
  303. // Advance time past TTL
  304. vi.advanceTimersByTime(2000);
  305. const cleaned = await shortTtlPool.cleanup();
  306. expect(cleaned).toBe(1);
  307. expect(shortTtlPool.getPoolStats().cacheSize).toBe(0);
  308. await shortTtlPool.shutdown();
  309. });
  310. it("should not cleanup recently used Agents", async () => {
  311. const shortTtlPool = new AgentPoolImpl({
  312. ...defaultConfig,
  313. agentTtlMs: 1000,
  314. });
  315. const params = {
  316. endpointUrl: "https://api.anthropic.com/v1/messages",
  317. proxyUrl: null,
  318. enableHttp2: false,
  319. };
  320. await shortTtlPool.getAgent(params);
  321. // Advance time but not past TTL
  322. vi.advanceTimersByTime(500);
  323. // Use the agent again (updates lastUsedAt)
  324. await shortTtlPool.getAgent(params);
  325. // Advance time again
  326. vi.advanceTimersByTime(500);
  327. const cleaned = await shortTtlPool.cleanup();
  328. expect(cleaned).toBe(0);
  329. expect(shortTtlPool.getPoolStats().cacheSize).toBe(1);
  330. await shortTtlPool.shutdown();
  331. });
  332. it("should implement LRU eviction when max size reached", async () => {
  333. const smallPool = new AgentPoolImpl({
  334. ...defaultConfig,
  335. maxTotalAgents: 2,
  336. });
  337. // Create 3 agents (exceeds max of 2)
  338. await smallPool.getAgent({
  339. endpointUrl: "https://api1.example.com/v1",
  340. proxyUrl: null,
  341. enableHttp2: false,
  342. });
  343. vi.advanceTimersByTime(100);
  344. await smallPool.getAgent({
  345. endpointUrl: "https://api2.example.com/v1",
  346. proxyUrl: null,
  347. enableHttp2: false,
  348. });
  349. vi.advanceTimersByTime(100);
  350. await smallPool.getAgent({
  351. endpointUrl: "https://api3.example.com/v1",
  352. proxyUrl: null,
  353. enableHttp2: false,
  354. });
  355. // Should have evicted the oldest (LRU)
  356. const stats = smallPool.getPoolStats();
  357. expect(stats.cacheSize).toBeLessThanOrEqual(2);
  358. await smallPool.shutdown();
  359. });
  360. });
  361. describe("reference counting", () => {
  362. it("should prevent expiration of agents with active requests", async () => {
  363. const shortTtlPool = new AgentPoolImpl({
  364. ...defaultConfig,
  365. agentTtlMs: 1000,
  366. });
  367. const params = {
  368. endpointUrl: "https://api.anthropic.com/v1/messages",
  369. proxyUrl: null,
  370. enableHttp2: false,
  371. };
  372. // getAgent increments activeRequests
  373. await shortTtlPool.getAgent(params);
  374. expect(shortTtlPool.getPoolStats().activeRequests).toBe(1);
  375. // Advance past TTL
  376. vi.advanceTimersByTime(2000);
  377. // Should NOT be evicted because activeRequests > 0
  378. const cleaned = await shortTtlPool.cleanup();
  379. expect(cleaned).toBe(0);
  380. expect(shortTtlPool.getPoolStats().cacheSize).toBe(1);
  381. await shortTtlPool.shutdown();
  382. });
  383. it("should allow expiration after all requests are released", async () => {
  384. const shortTtlPool = new AgentPoolImpl({
  385. ...defaultConfig,
  386. agentTtlMs: 1000,
  387. });
  388. const params = {
  389. endpointUrl: "https://api.anthropic.com/v1/messages",
  390. proxyUrl: null,
  391. enableHttp2: false,
  392. };
  393. const { cacheKey, dispatcherId } = await shortTtlPool.getAgent(params);
  394. shortTtlPool.releaseAgent(cacheKey, dispatcherId);
  395. expect(shortTtlPool.getPoolStats().activeRequests).toBe(0);
  396. // Advance past TTL
  397. vi.advanceTimersByTime(2000);
  398. const cleaned = await shortTtlPool.cleanup();
  399. expect(cleaned).toBe(1);
  400. expect(shortTtlPool.getPoolStats().cacheSize).toBe(0);
  401. await shortTtlPool.shutdown();
  402. });
  403. it("should track multiple sequential requests correctly", async () => {
  404. const shortTtlPool = new AgentPoolImpl({
  405. ...defaultConfig,
  406. agentTtlMs: 1000,
  407. });
  408. const params = {
  409. endpointUrl: "https://api.anthropic.com/v1/messages",
  410. proxyUrl: null,
  411. enableHttp2: false,
  412. };
  413. // 3 sequential requests to same agent (exercises the cache-hit path)
  414. const r1 = await shortTtlPool.getAgent(params);
  415. await shortTtlPool.getAgent(params);
  416. await shortTtlPool.getAgent(params);
  417. expect(shortTtlPool.getPoolStats().activeRequests).toBe(3);
  418. // Release 2
  419. shortTtlPool.releaseAgent(r1.cacheKey, r1.dispatcherId);
  420. shortTtlPool.releaseAgent(r1.cacheKey, r1.dispatcherId);
  421. expect(shortTtlPool.getPoolStats().activeRequests).toBe(1);
  422. // Advance past TTL - should NOT be evicted
  423. vi.advanceTimersByTime(2000);
  424. const cleaned1 = await shortTtlPool.cleanup();
  425. expect(cleaned1).toBe(0);
  426. // Release last request
  427. shortTtlPool.releaseAgent(r1.cacheKey, r1.dispatcherId);
  428. expect(shortTtlPool.getPoolStats().activeRequests).toBe(0);
  429. // Now advance past TTL - should be evicted
  430. vi.advanceTimersByTime(2000);
  431. const cleaned2 = await shortTtlPool.cleanup();
  432. expect(cleaned2).toBe(1);
  433. await shortTtlPool.shutdown();
  434. });
  435. it("should count pending-creation waiters in activeRequests", async () => {
  436. // 回归用例:真正模拟"首次创建阶段的并发请求"
  437. // 使用 vi.useRealTimers() 并通过 spy 阻塞 createAgent 以强制等待者走 pendingCreations 路径。
  438. // 如果 waiter 未正确递增 activeRequests,下面 expect(3) 会退化为 1 或 2。
  439. vi.useRealTimers();
  440. const concurrentPool = new AgentPoolImpl({
  441. ...defaultConfig,
  442. agentTtlMs: 60_000,
  443. });
  444. try {
  445. const params = {
  446. endpointUrl: "https://api.anthropic.com/v1/messages",
  447. proxyUrl: null,
  448. enableHttp2: false,
  449. };
  450. // 用 deferred 控制 createAgent 的完成时机,保证后续 getAgent 都落入 pendingCreations
  451. let releaseCreate: (() => void) | null = null;
  452. const createBlocker = new Promise<void>((resolve) => {
  453. releaseCreate = resolve;
  454. });
  455. // biome-ignore lint/suspicious/noExplicitAny: private 方法 spy
  456. const createSpy = vi.spyOn(concurrentPool as any, "createAgent");
  457. createSpy.mockImplementationOnce(async () => {
  458. await createBlocker;
  459. return {
  460. close: vi.fn().mockResolvedValue(undefined),
  461. destroy: vi.fn().mockResolvedValue(undefined),
  462. options: {},
  463. };
  464. });
  465. // 同时发起 3 个请求:首个进入 createAgentWithCache;后两个必须走 pendingCreations
  466. const p1 = concurrentPool.getAgent(params);
  467. const p2 = concurrentPool.getAgent(params);
  468. const p3 = concurrentPool.getAgent(params);
  469. // 稍微等一个 microtask,确保 p2/p3 已经进入 await pending 分支
  470. await Promise.resolve();
  471. await Promise.resolve();
  472. // 放行创建
  473. releaseCreate?.();
  474. const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
  475. // 三个调用应拿到相同的 cacheKey 与 dispatcherId
  476. expect(r2.cacheKey).toBe(r1.cacheKey);
  477. expect(r3.cacheKey).toBe(r1.cacheKey);
  478. expect(r2.dispatcherId).toBe(r1.dispatcherId);
  479. expect(r3.dispatcherId).toBe(r1.dispatcherId);
  480. // 关键断言:所有 3 个并发 waiter 都应计入 activeRequests
  481. expect(concurrentPool.getPoolStats().activeRequests).toBe(3);
  482. // 释放 3 次后归零,且仍能再多释一次(no-op)
  483. concurrentPool.releaseAgent(r1.cacheKey, r1.dispatcherId);
  484. concurrentPool.releaseAgent(r2.cacheKey, r2.dispatcherId);
  485. concurrentPool.releaseAgent(r3.cacheKey, r3.dispatcherId);
  486. expect(concurrentPool.getPoolStats().activeRequests).toBe(0);
  487. createSpy.mockRestore();
  488. } finally {
  489. await concurrentPool.shutdown();
  490. vi.useFakeTimers();
  491. }
  492. });
  493. it("should ignore releaseAgent from stale dispatcher generation", async () => {
  494. // 回归用例:cacheKey 相同但 dispatcher 已被重建时,旧 release 不应误减新实例
  495. const regenPool = new AgentPoolImpl({
  496. ...defaultConfig,
  497. agentTtlMs: 1000,
  498. });
  499. const params = {
  500. endpointUrl: "https://api.anthropic.com/v1/messages",
  501. proxyUrl: null,
  502. enableHttp2: false,
  503. };
  504. // 第一代 dispatcher
  505. const r1 = await regenPool.getAgent(params);
  506. expect(regenPool.getPoolStats().activeRequests).toBe(1);
  507. // 模拟外部强制驱逐(例如 markUnhealthy 后下次 getAgent 触发 evictByKey)
  508. regenPool.markUnhealthy(r1.cacheKey, "simulated SSL failure");
  509. // 第二代 dispatcher(同 cacheKey,但 dispatcherId 必须不同)
  510. const r2 = await regenPool.getAgent(params);
  511. expect(r2.cacheKey).toBe(r1.cacheKey);
  512. expect(r2.dispatcherId).not.toBe(r1.dispatcherId);
  513. expect(regenPool.getPoolStats().activeRequests).toBe(1);
  514. // 用第一代 dispatcherId 释放 —— 必须被忽略
  515. regenPool.releaseAgent(r1.cacheKey, r1.dispatcherId);
  516. expect(regenPool.getPoolStats().activeRequests).toBe(1);
  517. // 用第二代 dispatcherId 释放 —— 正常减到 0
  518. regenPool.releaseAgent(r2.cacheKey, r2.dispatcherId);
  519. expect(regenPool.getPoolStats().activeRequests).toBe(0);
  520. await regenPool.shutdown();
  521. });
  522. it("should refresh lastUsedAt on release", async () => {
  523. const shortTtlPool = new AgentPoolImpl({
  524. ...defaultConfig,
  525. agentTtlMs: 1000,
  526. });
  527. const params = {
  528. endpointUrl: "https://api.anthropic.com/v1/messages",
  529. proxyUrl: null,
  530. enableHttp2: false,
  531. };
  532. const { cacheKey, dispatcherId } = await shortTtlPool.getAgent(params);
  533. // Advance 800ms (close to TTL but not past)
  534. vi.advanceTimersByTime(800);
  535. // Release refreshes lastUsedAt
  536. shortTtlPool.releaseAgent(cacheKey, dispatcherId);
  537. // Advance another 500ms (total 1300ms from start, but only 500ms from release)
  538. vi.advanceTimersByTime(500);
  539. // Should NOT be evicted (TTL reset by release)
  540. const cleaned = await shortTtlPool.cleanup();
  541. expect(cleaned).toBe(0);
  542. await shortTtlPool.shutdown();
  543. });
  544. it("should force-expire after hard upper bound regardless of active requests", async () => {
  545. const pool2 = new AgentPoolImpl({
  546. ...defaultConfig,
  547. agentTtlMs: 1000,
  548. });
  549. const params = {
  550. endpointUrl: "https://api.anthropic.com/v1/messages",
  551. proxyUrl: null,
  552. enableHttp2: false,
  553. };
  554. // getAgent increments activeRequests (never released)
  555. await pool2.getAgent(params);
  556. expect(pool2.getPoolStats().activeRequests).toBe(1);
  557. // Advance past 30-minute hard upper bound
  558. vi.advanceTimersByTime(31 * 60 * 1000);
  559. const cleaned = await pool2.cleanup();
  560. expect(cleaned).toBe(1);
  561. expect(pool2.getPoolStats().cacheSize).toBe(0);
  562. await pool2.shutdown();
  563. });
  564. it("should be a no-op when releasing non-existent key", () => {
  565. // Should not throw
  566. pool.releaseAgent("nonexistent-key", "disp-1");
  567. expect(pool.getPoolStats().activeRequests).toBe(0);
  568. });
  569. it("should not go below zero on over-release", async () => {
  570. const params = {
  571. endpointUrl: "https://api.anthropic.com/v1/messages",
  572. proxyUrl: null,
  573. enableHttp2: false,
  574. };
  575. const { cacheKey, dispatcherId } = await pool.getAgent(params);
  576. pool.releaseAgent(cacheKey, dispatcherId);
  577. // Release again when already at 0
  578. pool.releaseAgent(cacheKey, dispatcherId);
  579. expect(pool.getPoolStats().activeRequests).toBe(0);
  580. });
  581. it("should include activeRequests in pool stats", async () => {
  582. const params = {
  583. endpointUrl: "https://api.anthropic.com/v1/messages",
  584. proxyUrl: null,
  585. enableHttp2: false,
  586. };
  587. expect(pool.getPoolStats().activeRequests).toBe(0);
  588. await pool.getAgent(params);
  589. expect(pool.getPoolStats().activeRequests).toBe(1);
  590. await pool.getAgent(params);
  591. expect(pool.getPoolStats().activeRequests).toBe(2);
  592. const { cacheKey, dispatcherId } = await pool.getAgent(params);
  593. expect(pool.getPoolStats().activeRequests).toBe(3);
  594. pool.releaseAgent(cacheKey, dispatcherId);
  595. expect(pool.getPoolStats().activeRequests).toBe(2);
  596. });
  597. });
  598. describe("proxy support", () => {
  599. it("should create ProxyAgent for HTTP proxy", async () => {
  600. const result = await pool.getAgent({
  601. endpointUrl: "https://api.anthropic.com/v1/messages",
  602. proxyUrl: "http://proxy.example.com:8080",
  603. enableHttp2: false,
  604. });
  605. expect(result.isNew).toBe(true);
  606. expect(result.cacheKey).toContain("http://proxy.example.com:8080");
  607. });
  608. it("should create SOCKS dispatcher for SOCKS proxy", async () => {
  609. const result = await pool.getAgent({
  610. endpointUrl: "https://api.anthropic.com/v1/messages",
  611. proxyUrl: "socks5://proxy.example.com:1080",
  612. enableHttp2: false,
  613. });
  614. expect(result.isNew).toBe(true);
  615. expect(result.cacheKey).toContain("socks5://proxy.example.com:1080");
  616. });
  617. });
  618. describe("pool stats", () => {
  619. it("should return accurate pool statistics", async () => {
  620. await pool.getAgent({
  621. endpointUrl: "https://api.anthropic.com/v1/messages",
  622. proxyUrl: null,
  623. enableHttp2: false,
  624. });
  625. await pool.getAgent({
  626. endpointUrl: "https://api.anthropic.com/v1/messages",
  627. proxyUrl: null,
  628. enableHttp2: false,
  629. });
  630. await pool.getAgent({
  631. endpointUrl: "https://api.openai.com/v1/chat/completions",
  632. proxyUrl: null,
  633. enableHttp2: false,
  634. });
  635. const stats = pool.getPoolStats();
  636. expect(stats.cacheSize).toBe(2);
  637. expect(stats.totalRequests).toBe(3);
  638. expect(stats.cacheHits).toBe(1);
  639. expect(stats.cacheMisses).toBe(2);
  640. expect(stats.hitRate).toBeCloseTo(1 / 3, 2);
  641. });
  642. });
  643. describe("shutdown", () => {
  644. it("should close all agents on shutdown", async () => {
  645. await pool.getAgent({
  646. endpointUrl: "https://api.anthropic.com/v1/messages",
  647. proxyUrl: null,
  648. enableHttp2: false,
  649. });
  650. await pool.getAgent({
  651. endpointUrl: "https://api.openai.com/v1/chat/completions",
  652. proxyUrl: null,
  653. enableHttp2: false,
  654. });
  655. await pool.shutdown();
  656. const stats = pool.getPoolStats();
  657. expect(stats.cacheSize).toBe(0);
  658. });
  659. it("should prefer destroy over close to avoid hanging on in-flight streaming requests", async () => {
  660. const result = await pool.getAgent({
  661. endpointUrl: "https://api.anthropic.com/v1/messages",
  662. proxyUrl: null,
  663. enableHttp2: true,
  664. });
  665. const agent = result.agent as unknown as {
  666. close?: () => Promise<void>;
  667. destroy?: () => Promise<void>;
  668. };
  669. // 说明:本文件顶部已 mock undici Agent/ProxyAgent,因此 destroy/close 应为 vi.fn,断言才有意义
  670. if (typeof agent.destroy === "function") {
  671. expect(vi.isMockFunction(agent.destroy)).toBe(true);
  672. }
  673. if (typeof agent.close === "function") {
  674. expect(vi.isMockFunction(agent.close)).toBe(true);
  675. }
  676. // 模拟:close 可能因等待 in-flight 请求结束而长期不返回
  677. if (typeof agent.close === "function") {
  678. vi.mocked(agent.close).mockImplementation(() => new Promise<void>(() => {}));
  679. }
  680. await pool.shutdown();
  681. // destroy 应被优先调用(避免 close 挂死导致 shutdown/evict 卡住)
  682. if (typeof agent.destroy === "function") {
  683. expect(agent.destroy).toHaveBeenCalled();
  684. }
  685. if (typeof agent.close === "function") {
  686. expect(agent.close).not.toHaveBeenCalled();
  687. }
  688. });
  689. });
  690. });
  691. describe("getGlobalAgentPool", () => {
  692. afterEach(async () => {
  693. await resetGlobalAgentPool();
  694. });
  695. it("should return singleton instance", () => {
  696. const pool1 = getGlobalAgentPool();
  697. const pool2 = getGlobalAgentPool();
  698. expect(pool1).toBe(pool2);
  699. });
  700. it("should create new instance after reset", async () => {
  701. const pool1 = getGlobalAgentPool();
  702. await resetGlobalAgentPool();
  703. const pool2 = getGlobalAgentPool();
  704. expect(pool1).not.toBe(pool2);
  705. });
  706. });