From 38fa3f21272f7d6977dae94235bc6978c42dd3bf Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Tue, 22 Sep 2026 19:32:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=9A=A7=E9=81=93=E6=96=AD=E7=BA=BF?= =?UTF-8?q?=E8=87=AA=E6=84=88=20+=20=E4=BF=AE=E5=A4=8D=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=B4=9F=E7=BC=93=E5=AD=98=E6=B0=B8=E4=B9=85=E5=9B=BA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 远端 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。 --- backend/src/config/cloudDatabase.js | 61 +++++++-- backend/test/cloudDatabaseConfigCache.test.js | 126 ++++++++++++++++++ scripts/start-remote.mjs | 115 +++++++++++++--- test/remote-access.test.mjs | 61 ++++++++- 4 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 backend/test/cloudDatabaseConfigCache.test.js diff --git a/backend/src/config/cloudDatabase.js b/backend/src/config/cloudDatabase.js index 405346b..37c7d2f 100644 --- a/backend/src/config/cloudDatabase.js +++ b/backend/src/config/cloudDatabase.js @@ -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(); }; /** diff --git a/backend/test/cloudDatabaseConfigCache.test.js b/backend/test/cloudDatabaseConfigCache.test.js new file mode 100644 index 0000000..260b577 --- /dev/null +++ b/backend/test/cloudDatabaseConfigCache.test.js @@ -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 反复淘汰(否则每个请求都要多打一次系统库)' + ); +}); diff --git a/scripts/start-remote.mjs b/scripts/start-remote.mjs index ad0c54f..b6ebf3a 100644 --- a/scripts/start-remote.mjs +++ b/scripts/start-remote.mjs @@ -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' diff --git a/test/remote-access.test.mjs b/test/remote-access.test.mjs index 380cde0..0e447f9 100644 --- a/test/remote-access.test.mjs +++ b/test/remote-access.test.mjs @@ -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 时疯狂重连打爆对端' + ); +}); -- 2.47.2