Compare commits
2 Commits
a58fc7d67b
...
f4303c0aed
| Author | SHA1 | Date | |
|---|---|---|---|
| f4303c0aed | |||
| 38fa3f2127 |
@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
126
backend/test/cloudDatabaseConfigCache.test.js
Normal file
126
backend/test/cloudDatabaseConfigCache.test.js
Normal 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 反复淘汰(否则每个请求都要多打一次系统库)'
|
||||
);
|
||||
});
|
||||
@ -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'
|
||||
|
||||
@ -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 时疯狂重连打爆对端'
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user