Merge pull request 'fix: 隧道断线自愈 + 修复配置负缓存永久固化(根治「隧道一断即整栈不可用 / 必须重启后端」)' (#21) from codex/tunnel-selfheal-config-cache-0922 into main
This commit is contained in:
commit
f4303c0aed
@ -19,6 +19,8 @@ const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app';
|
|||||||
let pool = null;
|
let pool = null;
|
||||||
let activeCloudConfig = null;
|
let activeCloudConfig = null;
|
||||||
let cachedCloudConfigCandidates = null;
|
let cachedCloudConfigCandidates = null;
|
||||||
|
// 负缓存(失败态)的过期时间戳。0 表示没有失败态缓存。
|
||||||
|
let cachedCloudConfigExpiresAt = 0;
|
||||||
|
|
||||||
function isPoolUsable(activePool) {
|
function isPoolUsable(activePool) {
|
||||||
return Boolean(activePool && activePool.connected && activePool.healthy !== false);
|
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_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_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 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);
|
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() {
|
async function resolveCloudConfigCandidates() {
|
||||||
if (cachedCloudConfigCandidates) {
|
// 缓存有效判据:**非 null** 且在有效期内。
|
||||||
|
//
|
||||||
|
// 旧实现是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy —— 一旦系统库
|
||||||
|
// 读取失败一次(隧道抖动),就会把空数组当作合法缓存永久返回,之后永不重试,
|
||||||
|
// 直接抛「业务数据库尚未配置」500,直到重启后端。这是典型的「永久负缓存」反模式。
|
||||||
|
//
|
||||||
|
// 现在:成功态 expiresAt = Infinity(靠 resetPool 显式失效);失败态带 30s TTL,
|
||||||
|
// 到期自动重新解析,隧道恢复后无需人工干预即可自愈。
|
||||||
|
if (cachedCloudConfigCandidates !== null && Date.now() < cachedCloudConfigExpiresAt) {
|
||||||
return cachedCloudConfigCandidates;
|
return cachedCloudConfigCandidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -705,6 +719,7 @@ async function resolveCloudConfigCandidates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cachedCloudConfigCandidates = candidates;
|
cachedCloudConfigCandidates = candidates;
|
||||||
|
cachedCloudConfigExpiresAt = Number.POSITIVE_INFINITY;
|
||||||
return cachedCloudConfigCandidates;
|
return cachedCloudConfigCandidates;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[CloudDB] 无法从数据库读取配置:', error.message);
|
console.warn('[CloudDB] 无法从数据库读取配置:', error.message);
|
||||||
@ -716,15 +731,23 @@ async function resolveCloudConfigCandidates() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedCloudConfigCandidates = [];
|
// 失败态:按需回退到环境变量配置,并把结果记为「带 TTL 的负缓存」。
|
||||||
|
const fallbacks = [];
|
||||||
if (hasCompleteEnvConfig(process.env, 'default')) {
|
if (hasCompleteEnvConfig(process.env, 'default')) {
|
||||||
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)');
|
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)');
|
||||||
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
|
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
|
||||||
}
|
}
|
||||||
if (hasCompleteEnvConfig(process.env, 'public')) {
|
if (hasCompleteEnvConfig(process.env, 'public')) {
|
||||||
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_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;
|
return cachedCloudConfigCandidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -961,10 +984,21 @@ const getPool = async () => {
|
|||||||
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
|
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一失效「云配置候选」缓存。
|
||||||
|
*
|
||||||
|
* 抽成单点:数组与负缓存 TTL 时间戳必须同时清,只清数组会让陈旧的 expiresAt 残留,
|
||||||
|
* 下次读取时 `Date.now() < expiresAt` 仍可能成立 → 又退化回「负缓存不失效」。
|
||||||
|
*/
|
||||||
|
function invalidateCloudConfigCache() {
|
||||||
|
cachedCloudConfigCandidates = null;
|
||||||
|
cachedCloudConfigExpiresAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
const resetPool = async () => {
|
const resetPool = async () => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
activeCloudConfig = null;
|
activeCloudConfig = null;
|
||||||
cachedCloudConfigCandidates = null;
|
invalidateCloudConfigCache();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -975,7 +1009,7 @@ const resetPool = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
pool = null;
|
pool = null;
|
||||||
activeCloudConfig = null;
|
activeCloudConfig = null;
|
||||||
cachedCloudConfigCandidates = null;
|
invalidateCloudConfigCache();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1026,6 +1060,14 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
|
|||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
const candidates = await resolveCloudConfigCandidates();
|
const candidates = await resolveCloudConfigCandidates();
|
||||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||||
|
// 候选为空 = 本轮配置解析失败,且没有任何环境变量兜底。
|
||||||
|
//
|
||||||
|
// 这里刻意**不**清缓存:失败态已由 resolveCloudConfigCandidates 写成「带 TTL 的负缓存」。
|
||||||
|
// TTL 内的后续请求快速失败(不必每个请求都去撞系统库的慢超时),到期自动重新解析 ——
|
||||||
|
// 隧道恢复后无需重启后端。旧实现把这次失败永久固化,正是「必须重启后端」的根因。
|
||||||
|
//
|
||||||
|
// 注:getPool() 里的同款分支行为与此保持一致,两条路径统一由 TTL 负责恢复;
|
||||||
|
// 「坏连接池」由 query() 的 catch → resetPool() 负责,各司其职。
|
||||||
throw new Error(NOT_CONFIGURED_MESSAGE);
|
throw new Error(NOT_CONFIGURED_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1052,6 +1094,9 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
|
|||||||
*/
|
*/
|
||||||
const test = async () => {
|
const test = async () => {
|
||||||
try {
|
try {
|
||||||
|
// 「测试连接」是用户在系统设置里验证配置的入口,必须读最新配置:
|
||||||
|
// 先显式失效,避免 30s 内的失败态负缓存让用户看到「刚改好却连不上」。
|
||||||
|
invalidateCloudConfigCache();
|
||||||
const candidates = await resolveCloudConfigCandidates();
|
const candidates = await resolveCloudConfigCandidates();
|
||||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||||
return { success: false, message: NOT_CONFIGURED_MESSAGE };
|
return { success: false, message: NOT_CONFIGURED_MESSAGE };
|
||||||
@ -1080,7 +1125,7 @@ const close = async () => {
|
|||||||
}
|
}
|
||||||
pool = null;
|
pool = null;
|
||||||
activeCloudConfig = 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;
|
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) {
|
export function buildSshArgs(config) {
|
||||||
return [
|
return [
|
||||||
'-N', '-T',
|
'-N', '-T',
|
||||||
'-o', 'BatchMode=yes',
|
'-o', 'BatchMode=yes',
|
||||||
|
// 远端仍可能主动 RST(中间设备/服务端空闲超时),本地探活只能更快发现,不能阻止;
|
||||||
|
// 真正的根治是下面的退避重连循环。
|
||||||
'-o', 'ExitOnForwardFailure=yes',
|
'-o', 'ExitOnForwardFailure=yes',
|
||||||
'-o', 'ServerAliveInterval=30',
|
'-o', 'ServerAliveInterval=15',
|
||||||
'-o', 'ServerAliveCountMax=3',
|
'-o', 'ServerAliveCountMax=3',
|
||||||
|
'-o', 'TCPKeepAlive=yes',
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
'-o', 'StrictHostKeyChecking=yes',
|
'-o', 'StrictHostKeyChecking=yes',
|
||||||
'-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`,
|
'-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`,
|
||||||
'-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`,
|
'-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);
|
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 startTunnel = async () => {
|
||||||
const { value: config } = loadConnectionConfig(ROOT_DIR);
|
const { value: config } = loadConnectionConfig(ROOT_DIR);
|
||||||
const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' });
|
const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' });
|
||||||
children.add(tunnel);
|
children.add(tunnel);
|
||||||
let ready = false;
|
|
||||||
const earlyExit = new Promise((resolve, reject) => {
|
const exitPromise = new Promise((resolve) => {
|
||||||
tunnel.once('exit', (code) => {
|
tunnel.once('exit', (code) => {
|
||||||
children.delete(tunnel);
|
children.delete(tunnel);
|
||||||
if (!ready) {
|
resolve(code);
|
||||||
reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`));
|
|
||||||
} else if (!shuttingDown) {
|
|
||||||
console.error(`SSH 隧道已退出(${code ?? 'unknown'})`);
|
|
||||||
stopChildren();
|
|
||||||
process.exitCode = code || 1;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
try {
|
|
||||||
await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]);
|
// 两个分支都 resolve,避免 Promise.race 落败方变成 unhandled rejection
|
||||||
ready = true;
|
const outcome = await Promise.race([
|
||||||
} catch (error) {
|
waitForPort(Number(config.localDatabasePort)).then(() => ({ ready: true })),
|
||||||
|
exitPromise.then((code) => ({ ready: false, code })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!outcome.ready) {
|
||||||
if (!tunnel.killed) tunnel.kill('SIGTERM');
|
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}`);
|
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';
|
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);
|
const hasConnection = fs.existsSync(CONNECTION_PATH);
|
||||||
if (hasConnection) {
|
if (hasConnection) {
|
||||||
if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH);
|
if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH);
|
||||||
await startTunnel();
|
startTunnelSupervisor();
|
||||||
|
await tunnelReady;
|
||||||
} else if (mode === 'tunnel') {
|
} else if (mode === 'tunnel') {
|
||||||
throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key');
|
throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key');
|
||||||
}
|
}
|
||||||
@ -195,7 +273,8 @@ export async function main(mode = process.argv[2] || 'tunnel') {
|
|||||||
await waitForExit(backend);
|
await waitForExit(backend);
|
||||||
restartingBackend = false;
|
restartingBackend = false;
|
||||||
fs.unlinkSync(RESTART_MARKER_PATH);
|
fs.unlinkSync(RESTART_MARKER_PATH);
|
||||||
await startTunnel();
|
startTunnelSupervisor();
|
||||||
|
await tunnelReady;
|
||||||
backend = startApplication(
|
backend = startApplication(
|
||||||
['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'],
|
['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'],
|
||||||
'backend'
|
'backend'
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs';
|
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 = {
|
const enrollment = {
|
||||||
installation_id: 'dev-123',
|
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', 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');
|
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