|
3 | 3 | import http from 'node:http'; |
4 | 4 | import { spawn } from 'node:child_process'; |
5 | 5 | import { setTimeout as sleep } from 'node:timers/promises'; |
6 | | -import { mkdtempSync, copyFileSync, existsSync } from 'node:fs'; |
| 6 | +import { mkdtempSync, copyFileSync, existsSync, rmSync } from 'node:fs'; |
7 | 7 | import { tmpdir } from 'node:os'; |
8 | 8 | import { join, dirname } from 'node:path'; |
9 | 9 | import { fileURLToPath } from 'node:url'; |
10 | 10 |
|
11 | 11 | export const REPO = dirname(fileURLToPath(import.meta.url)).replace(/[/\\]test$/, ''); |
12 | 12 |
|
13 | | -// 每个用例拿到独立端口。node --test 每个文件一个进程,用 pid 派生基数, |
14 | | -// 并用随机起点降低跨进程撞端口的概率。 |
15 | | -let nextPort = 40000 + (process.pid % 500) * 40 + Math.floor(Math.random() * 20); |
16 | | -export const allocPort = () => nextPort++; |
| 13 | +// 取一个当前空闲的端口:让内核分配(listen 0)后立刻释放。 |
| 14 | +// 不能用 pid 派生区间 —— node --test 各文件并行,pid 取模会在不同 pid 间 |
| 15 | +// 映射到同一区间(如 pid 100 与 pid 600 同桶),进而偶发 EADDRINUSE。 |
| 16 | +// 内核分配把冲突面缩到「释放到重新占用」之间的极小窗口,调用方另有重试兜底。 |
| 17 | +import net from 'node:net'; |
| 18 | +export async function allocPort() { |
| 19 | + return await new Promise((resolve, reject) => { |
| 20 | + const srv = net.createServer(); |
| 21 | + srv.once('error', reject); |
| 22 | + srv.listen(0, '127.0.0.1', () => { |
| 23 | + const { port } = srv.address(); |
| 24 | + srv.close(() => resolve(port)); |
| 25 | + }); |
| 26 | + }); |
| 27 | +} |
17 | 28 |
|
18 | 29 | /** 启动一个 mock 上游。ndjson 为要回给代理的 CC NDJSON 行数组。 */ |
19 | 30 | export async function startMockUpstream(opts = {}) { |
20 | | - const port = allocPort(); |
| 31 | + const port = await allocPort(); |
21 | 32 | const seen = []; |
22 | 33 | const server = http.createServer((req, res) => { |
23 | 34 | const chunks = []; |
@@ -56,8 +67,10 @@ export async function startMockUpstream(opts = {}) { |
56 | 67 |
|
57 | 68 | /** 在临时 cwd 中启动代理(复刻真实部署:proxy.mjs 与 config.json 同目录)。 */ |
58 | 69 | export async function startProxy({ upstreamPort, env = {}, cwd } = {}) { |
59 | | - const port = allocPort(); |
| 70 | + const port = await allocPort(); |
60 | 71 | const logs = []; |
| 72 | + // 自建的临时工作目录用完必须删;调用方传了 cwd 则由调用方负责。 |
| 73 | + const ownWorkdir = cwd === undefined; |
61 | 74 | const workdir = cwd ?? mkdtempSync(join(tmpdir(), 'ccp-test-')); |
62 | 75 | copyFileSync(join(REPO, 'proxy.mjs'), join(workdir, 'proxy.mjs')); |
63 | 76 | if (!existsSync(join(workdir, 'config.json'))) { |
@@ -90,7 +103,17 @@ export async function startProxy({ upstreamPort, env = {}, cwd } = {}) { |
90 | 103 | method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, |
91 | 104 | body: typeof body === 'string' ? body : JSON.stringify(body), |
92 | 105 | }), |
93 | | - kill: () => new Promise(r => { child.once('exit', r); child.kill(); setTimeout(r, 2000); }), |
| 106 | + kill: () => new Promise(r => { |
| 107 | + child.once('exit', () => { |
| 108 | + if (ownWorkdir) { try { rmSync(workdir, { recursive: true, force: true }); } catch {} } |
| 109 | + r(); |
| 110 | + }); |
| 111 | + child.kill(); |
| 112 | + setTimeout(() => { |
| 113 | + if (ownWorkdir) { try { rmSync(workdir, { recursive: true, force: true }); } catch {} } |
| 114 | + r(); |
| 115 | + }, 2000); |
| 116 | + }), |
94 | 117 | }; |
95 | 118 | } |
96 | 119 |
|
|
0 commit comments