fix: 隧道断线自愈 + 修复配置负缓存永久固化

远端 RST 会把隧道反复踢掉(09-20 实测当天断 5 次,每次存活 15~25 分钟)。原先的
实现把「一次网络抖动」放大成「整个开发栈不可用」,根因有两处,本次一并修掉。

1) scripts/start-remote.mjs —— 隧道断了却拉着应用陪葬

   隧道进程 exit 时直接 stopChildren(),把后端和前端一起杀掉,于是每一次 RST 都
   升级成一次「网站打不开」,还得靠外部守护清残留、整树重启才能恢复。

   改为「退避重连隧道本身,后端/前端进程不受影响」——这正是 autossh 的语义
   (autossh 只负责重启 ssh,从不牵连被它服务的应用)。建立失败走 1s→30s 指数
   退避,建立成功即重置退避。

   顺带补齐保活参数:TCPKeepAlive=yes、ConnectTimeout=10,探活间隔 30s → 15s,
   以便更快发现已被对端 RST 的死连接。

2) backend/src/config/cloudDatabase.js —— 配置负缓存被永久固化

   系统库读取失败时把「空候选」写进缓存,而入口判断是
   `if (cachedCloudConfigCandidates)` —— [] 在 JS 里是 truthy,于是这次失败被
   永久固化:之后永不重试,直接抛「业务数据库尚未配置」500,隧道恢复了也没用,
   必须重启后端。

   改为「带 TTL 的负缓存」(默认 30s,可用 CLOUD_DB_CONFIG_NEGATIVE_TTL_MS 调整):
   入口用 `!== null` 判断,失败态到期自动重新解析,使隧道恢复后无需人工干预。
   缓存清理收敛到 invalidateCloudConfigCache() 单点,避免日后只清数组、忘了清
   TTL 时间戳而退化回「永不过期」。同时让「测试连接」入口显式失效,保证用户改完
   配置点测试能立即看到真实结果。

   顺带把请求超时默认值 600000ms → 60000ms:10 分钟 × maxAttempts=2 最坏挂
   20 分钟,用户体感就是「卡死」;冷查实测 16~42s,60s 仍有充足余量。

验证:
- 新增 backend/test/cloudDatabaseConfigCache.test.js,锁定负缓存三段语义
  (TTL 内不重复解析 / 到期必须重试 / 上游恢复后自动复原且成功态不被 TTL 淘汰);
  test/remote-access.test.mjs 补隧道保活参数、退避曲线,以及「自愈循环不得调用
  stopChildren」的防腐化断言。
- 两处均做对照组实验:去掉保护后测试如实报错(负缓存 → 报 TTL 到期未重试;
  隧道 → 把 stopChildren 加回去即被用例抓住),证明用例真能拦住回归。
- 后端全量 340 项 / 339 通过,唯一失败为 resilientSecretStore 的 Windows 权限
  断言(环境固有,与本次无关);check-syntax 通过。
- 本地实测:kill 掉隧道进程后 1 秒内自动重建(PID 36268 → 15816),后端进程
  PID 未变(29580)、/health 仍返回 200。
This commit is contained in:
yanhui 2026-09-22 19:32:40 +08:00
parent 4946018251
commit 38fa3f2127
4 changed files with 336 additions and 27 deletions

View File

@ -19,6 +19,8 @@ const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app';
let pool = null;
let activeCloudConfig = null;
let cachedCloudConfigCandidates = null;
// 负缓存(失败态)的过期时间戳。0 表示没有失败态缓存。
let cachedCloudConfigExpiresAt = 0;
function isPoolUsable(activePool) {
return Boolean(activePool && activePool.connected && activePool.healthy !== false);
@ -30,7 +32,11 @@ const parsePositiveInt = (value, fallback) => {
};
const DEFAULT_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS, 30000);
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 600000);
// 原先默认 600000ms(10 分钟),配合 maxAttempts=2 最坏可挂 20 分钟,用户体感就是「卡死」。
// 收敛到 60s:本页最重的冷查实测 16~42s,仍有充足余量。
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 60000);
// 解析「系统库配置」失败时的负缓存存活时间。到期后自动重试,使隧道恢复后无需重启后端。
const NEGATIVE_CONFIG_CACHE_TTL_MS = parsePositiveInt(process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS, 30000);
const CLOUD_DB_POOL_MAX = parsePositiveInt(process.env.CLOUD_DB_POOL_MAX, 10);
const CLOUD_DB_POOL_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_POOL_IDLE_TIMEOUT_MS, 30000);
const LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000);
@ -673,7 +679,15 @@ async function loadCandidateConfig(localPool, profile) {
}
async function resolveCloudConfigCandidates() {
if (cachedCloudConfigCandidates) {
// 缓存有效判据:**非 null** 且在有效期内。
//
// 旧实现是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy —— 一旦系统库
// 读取失败一次(隧道抖动),就会把空数组当作合法缓存永久返回,之后永不重试,
// 直接抛「业务数据库尚未配置」500,直到重启后端。这是典型的「永久负缓存」反模式。
//
// 现在:成功态 expiresAt = Infinity(靠 resetPool 显式失效);失败态带 30s TTL,
// 到期自动重新解析,隧道恢复后无需人工干预即可自愈。
if (cachedCloudConfigCandidates !== null && Date.now() < cachedCloudConfigExpiresAt) {
return cachedCloudConfigCandidates;
}
@ -705,6 +719,7 @@ async function resolveCloudConfigCandidates() {
}
cachedCloudConfigCandidates = candidates;
cachedCloudConfigExpiresAt = Number.POSITIVE_INFINITY;
return cachedCloudConfigCandidates;
} catch (error) {
console.warn('[CloudDB] 无法从数据库读取配置:', error.message);
@ -716,15 +731,23 @@ async function resolveCloudConfigCandidates() {
}
}
cachedCloudConfigCandidates = [];
// 失败态:按需回退到环境变量配置,并把结果记为「带 TTL 的负缓存」。
const fallbacks = [];
if (hasCompleteEnvConfig(process.env, 'default')) {
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)');
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
}
if (hasCompleteEnvConfig(process.env, 'public')) {
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_PUBLIC_*)');
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
}
cachedCloudConfigCandidates = fallbacks;
cachedCloudConfigExpiresAt = Date.now() + NEGATIVE_CONFIG_CACHE_TTL_MS;
console.warn(
`[CloudDB] 系统库配置暂不可用(${fallbacks.length > 0 ? '暂用环境变量兜底' : '且无环境变量兜底'}),`
+ `${NEGATIVE_CONFIG_CACHE_TTL_MS}ms 后自动重试`
);
return cachedCloudConfigCandidates;
}
@ -961,10 +984,21 @@ const getPool = async () => {
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
};
/**
* 统一失效「云配置候选」缓存。
*
* 抽成单点:数组与负缓存 TTL 时间戳必须同时清,只清数组会让陈旧的 expiresAt 残留,
* 下次读取时 `Date.now() < expiresAt` 仍可能成立 → 又退化回「负缓存不失效」。
*/
function invalidateCloudConfigCache() {
cachedCloudConfigCandidates = null;
cachedCloudConfigExpiresAt = 0;
}
const resetPool = async () => {
if (!pool) {
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
invalidateCloudConfigCache();
return;
}
@ -975,7 +1009,7 @@ const resetPool = async () => {
} finally {
pool = null;
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
invalidateCloudConfigCache();
}
};
@ -1026,6 +1060,14 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
// 候选为空 = 本轮配置解析失败,且没有任何环境变量兜底。
//
// 这里刻意**不**清缓存:失败态已由 resolveCloudConfigCandidates 写成「带 TTL 的负缓存」。
// TTL 内的后续请求快速失败(不必每个请求都去撞系统库的慢超时),到期自动重新解析 ——
// 隧道恢复后无需重启后端。旧实现把这次失败永久固化,正是「必须重启后端」的根因。
//
// 注:getPool() 里的同款分支行为与此保持一致,两条路径统一由 TTL 负责恢复;
// 「坏连接池」由 query() 的 catch → resetPool() 负责,各司其职。
throw new Error(NOT_CONFIGURED_MESSAGE);
}
@ -1052,6 +1094,9 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
*/
const test = async () => {
try {
// 「测试连接」是用户在系统设置里验证配置的入口,必须读最新配置:
// 先显式失效,避免 30s 内的失败态负缓存让用户看到「刚改好却连不上」。
invalidateCloudConfigCache();
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
return { success: false, message: NOT_CONFIGURED_MESSAGE };
@ -1080,7 +1125,7 @@ const close = async () => {
}
pool = null;
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
invalidateCloudConfigCache();
};
/**

View File

@ -0,0 +1,126 @@
/**
* 配置负缓存 TTL 回归测试。
*
* 背景(线上真实故障):
* 隧道抖动 → 系统库(走 SSH 隧道)读取失败 → 旧实现把「空候选」永久缓存下来
* (入口是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy),
* 此后永不重试,直接抛「业务数据库尚未配置」500,**必须重启后端**才能恢复。
*
* 修复:失败态记为「带 TTL 的负缓存」,到期自动重新解析。
* 本文件锁定三条语义:
* ① TTL 内不重复解析(不必每个请求都去撞系统库的慢超时)
* ② TTL 到期后必须重试
* ③ 上游恢复后能自动回到正常态,且成功态不会被 TTL 反复淘汰
*/
const test = require('node:test');
const assert = require('node:assert/strict');
// ── 必须在加载被测模块之前完成环境与替身准备 ─────────────────────────────
// 1) 先清掉业务库环境变量,稳定走「无环境变量兜底」分支(否则 fallbacks 非空,不会报未配置)
for (const key of Object.keys(process.env)) {
if (/^(CLOUD_DB|APP_DB|SQLSERVER)_/u.test(key)) {
delete process.env[key];
}
}
// 2) 再缩短负缓存 TTL —— 必须在上面那次清理之后,否则会被同一前缀规则一并删掉
process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS = '150';
// 3) 让「系统库读取」可控:替换 mysql2/promise 的 createPool
const MYSQL_PROMISE_PATH = require.resolve('mysql2/promise');
const realMysqlPromise = require(MYSQL_PROMISE_PATH);
let systemDbDialAttempts = 0;
let systemDbMode = 'down'; // down = 模拟隧道断开;recovered = 模拟隧道已恢复
const systemConfigRow = {
name: 'cloud_database',
host: '127.0.0.1',
port: 59999, // 故意指向无人监听的端口,避免测试真去连业务库
database_name: 'JSReportReplica',
username: 'probe',
password: 'probe',
ssl: 0,
driver: 'mssql',
use_env_config: 0,
is_default: 1,
};
require.cache[MYSQL_PROMISE_PATH].exports = Object.assign({}, realMysqlPromise, {
createPool: () => {
systemDbDialAttempts += 1;
return {
query: async () => {
if (systemDbMode === 'down') {
throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:13307'), {
code: 'ECONNREFUSED',
});
}
return [[systemConfigRow]];
},
end: async () => {},
};
},
});
const cloudDatabase = require('../src/config/cloudDatabase');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const NOT_CONFIGURED = /业务数据库尚未配置/u;
test('负缓存带 TTL:TTL 内不重复解析,到期后必须重试', async () => {
await cloudDatabase.close();
const base = systemDbDialAttempts;
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(systemDbDialAttempts, base + 1, '首次查询应真正尝试解析配置');
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(systemDbDialAttempts, base + 1, 'TTL 内应命中负缓存,不重复解析');
await sleep(220);
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(
systemDbDialAttempts,
base + 2,
'TTL 到期后必须重试 —— 这正是「隧道恢复后无需重启后端」的关键'
);
});
test('隧道恢复后自动复原:TTL 到期重试能读回配置,且成功态不被 TTL 淘汰', async () => {
await sleep(220); // 越过上一轮的负缓存 TTL
systemDbMode = 'recovered';
const base = systemDbDialAttempts;
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
(error) => {
// 关键断言:不应再是「尚未配置」——说明配置已成功读回,
// 失败只是发生在后面连业务库那一层(这里故意指向空端口)。
assert.doesNotMatch(
error.message,
NOT_CONFIGURED,
'配置读取应已恢复,不该再报「业务数据库尚未配置」'
);
return true;
}
);
assert.ok(systemDbDialAttempts > base, '上游恢复后应重新解析配置');
// 成功态 expiresAt = Infinity:后续查询不得再反复解析系统库
const afterRecovery = systemDbDialAttempts;
await assert.rejects(() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'));
assert.equal(
systemDbDialAttempts,
afterRecovery,
'成功态不应被 TTL 反复淘汰(否则每个请求都要多打一次系统库)'
);
});

View File

@ -34,13 +34,34 @@ function resolveBackendPort() {
return 4001;
}
/**
* 隧道重连退避:1s → 2s → 4s … 封顶 30s。
* 首次失败也立即重试(等价 autossh 的 AUTOSSH_GATETIME=0),避免网络尚未就绪时白等。
*/
const TUNNEL_RETRY_BASE_MS = 1000;
const TUNNEL_RETRY_MAX_MS = 30000;
const TUNNEL_RETRY_EXPONENT_CAP = 10;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function computeTunnelRetryDelay(attempt) {
const exponent = Math.min(attempt, TUNNEL_RETRY_EXPONENT_CAP);
return Math.min(TUNNEL_RETRY_BASE_MS * 2 ** exponent, TUNNEL_RETRY_MAX_MS);
}
export function buildSshArgs(config) {
return [
'-N', '-T',
'-o', 'BatchMode=yes',
// 远端仍可能主动 RST(中间设备/服务端空闲超时),本地探活只能更快发现,不能阻止;
// 真正的根治是下面的退避重连循环。
'-o', 'ExitOnForwardFailure=yes',
'-o', 'ServerAliveInterval=30',
'-o', 'ServerAliveInterval=15',
'-o', 'ServerAliveCountMax=3',
'-o', 'TCPKeepAlive=yes',
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=yes',
'-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`,
'-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`,
@ -112,32 +133,88 @@ export async function main(mode = process.argv[2] || 'tunnel') {
process.once(signal, stopChildren);
}
let tunnelRetryAttempt = 0;
let resolveTunnelReady = null;
const tunnelReady = new Promise((resolve) => {
resolveTunnelReady = resolve;
});
/**
* 启动一条 SSH 隧道并等它监听就绪。
* 返回 { tunnel, exitPromise };隧道结束由 exitPromise 上报退出码,交给
* superviseTunnel 决定重连 —— 这里**不再**牵连后端/前端。
*/
const startTunnel = async () => {
const { value: config } = loadConnectionConfig(ROOT_DIR);
const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' });
children.add(tunnel);
let ready = false;
const earlyExit = new Promise((resolve, reject) => {
const exitPromise = new Promise((resolve) => {
tunnel.once('exit', (code) => {
children.delete(tunnel);
if (!ready) {
reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`));
} else if (!shuttingDown) {
console.error(`SSH 隧道已退出(${code ?? 'unknown'})`);
stopChildren();
process.exitCode = code || 1;
}
resolve(code);
});
});
try {
await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]);
ready = true;
} catch (error) {
// 两个分支都 resolve,避免 Promise.race 落败方变成 unhandled rejection
const outcome = await Promise.race([
waitForPort(Number(config.localDatabasePort)).then(() => ({ ready: true })),
exitPromise.then((code) => ({ ready: false, code })),
]);
if (!outcome.ready) {
if (!tunnel.killed) tunnel.kill('SIGTERM');
throw error;
throw new Error(`SSH 隧道在建立前退出(${outcome.code ?? 'unknown'})`);
}
console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`);
return tunnel;
return { tunnel, exitPromise };
};
/**
* 隧道自愈循环(Windows 版 autossh 的语义)。
*
* 隧道断开只重连隧道本身,后端/前端进程保持不动。这是原先缺失的容错:
* 旧实现在隧道 exit 时直接 stopChildren(),把整个开发栈一起杀掉,于是每一次
* 远端 RST 都升级成一次「网站打不开」,还得靠外部守护清残留、整树重启。
*
* 建立失败走指数退避(1s→30s);建立成功后重置退避,断开则固定 1s 后重连。
*/
const superviseTunnel = async () => {
for (;;) {
if (shuttingDown) return;
let session;
try {
session = await startTunnel();
tunnelRetryAttempt = 0;
if (resolveTunnelReady) {
resolveTunnelReady();
resolveTunnelReady = null;
}
} catch (error) {
if (shuttingDown) return;
const delay = computeTunnelRetryDelay(tunnelRetryAttempt);
tunnelRetryAttempt += 1;
console.error(`SSH 隧道建立失败(${error.message}),${delay}ms 后重试`);
await sleep(delay);
continue;
}
const code = await session.exitPromise;
if (shuttingDown) return;
console.error(`SSH 隧道已退出(${code ?? 'unknown'}),正在重连…(后端/前端不受影响)`);
await sleep(TUNNEL_RETRY_BASE_MS);
}
};
// 守护循环本身理论上不抛错,兜底仍保留:真异常时按老路径收尾,避免留下半死进程
const startTunnelSupervisor = () => {
superviseTunnel().catch((error) => {
console.error(`隧道守护异常终止:${error.message}`);
stopChildren();
process.exitCode = 1;
});
};
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
@ -172,7 +249,8 @@ export async function main(mode = process.argv[2] || 'tunnel') {
const hasConnection = fs.existsSync(CONNECTION_PATH);
if (hasConnection) {
if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH);
await startTunnel();
startTunnelSupervisor();
await tunnelReady;
} else if (mode === 'tunnel') {
throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key');
}
@ -195,7 +273,8 @@ export async function main(mode = process.argv[2] || 'tunnel') {
await waitForExit(backend);
restartingBackend = false;
fs.unlinkSync(RESTART_MARKER_PATH);
await startTunnel();
startTunnelSupervisor();
await tunnelReady;
backend = startApplication(
['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'],
'backend'

View File

@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs';
import { buildSshArgs } from '../scripts/start-remote.mjs';
import { buildSshArgs, computeTunnelRetryDelay } from '../scripts/start-remote.mjs';
const enrollment = {
installation_id: 'dev-123',
@ -69,3 +70,61 @@ test('pins standard and non-standard SSH host targets', () => {
assert.equal(getKnownHostsLine('example.test', 22, 'ssh-ed25519 KEY'), 'example.test ssh-ed25519 KEY\n');
assert.equal(getKnownHostsLine('example.test', 2222, 'ssh-ed25519 KEY'), '[example.test]:2222 ssh-ed25519 KEY\n');
});
// ─────────────────────────────────────────────────────────────────────────────
// 隧道自愈(根治远端 RST 导致「网站打不开」)
//
// 旧行为:隧道进程 exit → stopChildren(),把后端 + 前端一起杀掉,于是每一次远端
// RST 都升级成一次整栈不可用,还得靠外部守护清残留、整树重启。
// 新行为:隧道 exit → 只重连隧道本身,应用进程不受影响。
// ─────────────────────────────────────────────────────────────────────────────
const tunnelConfig = {
localDatabasePort: 13307,
ssh: {
host: 'example.test',
port: 50232,
user: 'cangku-tunnel',
remoteDatabaseHost: '127.0.0.1',
remoteDatabasePort: 3307,
},
};
test('tunnel keepalive: 更快发现死连接,且不会在 connect 阶段无限等待', () => {
const flags = new Set(buildSshArgs(tunnelConfig));
// ExitOnForwardFailure 缺失会出现最阴险的故障:进程活着、端口却没绑上(「静默成功」)
assert.ok(flags.has('ExitOnForwardFailure=yes'), '必须保留 ExitOnForwardFailure=yes');
assert.ok(flags.has('ServerAliveInterval=15'), '探活间隔应为 15s,以便更快发现被 RST 的连接');
assert.ok(flags.has('ServerAliveCountMax=3'));
assert.ok(flags.has('TCPKeepAlive=yes'), '需要内核层保活兜底');
assert.ok(flags.has('ConnectTimeout=10'), '连不上时应快速失败,交给退避重连而不是干等');
});
test('tunnel retry backoff: 1s 起指数增长,封顶 30s 且不会溢出', () => {
assert.equal(computeTunnelRetryDelay(0), 1000);
assert.equal(computeTunnelRetryDelay(1), 2000);
assert.equal(computeTunnelRetryDelay(2), 4000);
assert.equal(computeTunnelRetryDelay(4), 16000);
assert.equal(computeTunnelRetryDelay(5), 30000);
// 长期不可用时 attempt 会一路增长,必须封顶而不是算成 Infinity / 0
assert.equal(computeTunnelRetryDelay(200), 30000);
assert.ok(Number.isFinite(computeTunnelRetryDelay(2000)));
});
test('防腐化:隧道自愈循环不得停止后端/前端进程', async () => {
const source = await readFile(new URL('../scripts/start-remote.mjs', import.meta.url), 'utf8');
const start = source.indexOf('const superviseTunnel = async () => {');
const end = source.indexOf('const startTunnelSupervisor = () => {');
assert.ok(start > 0 && end > start, '未定位到 superviseTunnel 实现');
const body = source.slice(start, end);
assert.equal(
body.includes('stopChildren()'),
false,
'隧道重连不得牵连后端/前端 —— 否则又回到「隧道一断,整个开发栈陪葬」'
);
assert.ok(
body.includes('await sleep('),
'隧道退出后必须先等待再重连,避免 RST 时疯狂重连打爆对端'
);
});