338 lines
14 KiB
JavaScript
338 lines
14 KiB
JavaScript
/**
|
||
* APS 桌面端「升级 nonce 续接」协调器(round-46 KK,矩阵 103 最后本地项)。
|
||
*
|
||
* 背景(round-43~45 链路):
|
||
* - sidecar 每次启动/重启都会轮换进程 nonce(x-aps-sidecar-nonce,见 sidecar.cjs),
|
||
* 升级安装(NSIS /S)会替换安装目录内的 sidecar 产物,运行中的 sidecar 可能随之中断,
|
||
* 由 SidecarManager 以「新进程 + 新 nonce」重启并触发 main 的 'restarted' 事件;
|
||
* - updater.applyUpdate 安装后健康检查使用的是调用时(升级前/升级中)拿到的旧 nonce,
|
||
* 无法保证能通过升级后新网关的 nonce 门禁(缺失/不匹配 → 403,见 probeHealth);
|
||
* - round-45 HH 已让 applyUpdate 回传 report.nonce、接受 sidecarNonce 续接参数;
|
||
* 本模块补齐 main 侧接线:sidecar 重启后把「新 nonce」交给待验证的升级流程重跑
|
||
* probeHealth(升级后健康检查 + 版本比对)。
|
||
*
|
||
* 本模块把「pending 升级状态机 + sidecar restarted 后重验证」的核心逻辑抽成可注入协调器
|
||
* createUpgradeNonceCoordinator({ verifyHealth, onComplete, onFailure }),不依赖 Electron,
|
||
* 可直接 node --test 单测(apps/desktop/test/upgrade-nonce.test.cjs)。Electron main
|
||
* (main.cjs)负责接线:
|
||
* - apply-update 前 setPending()(status='installing');
|
||
* - applyUpdate 返回后 markInstalled()(status='verifying',保留 pending 等待最终校验);
|
||
* - sidecar 'restarted' 事件调用 onSidecarRestarted({ origin, nonce }):
|
||
* 成功 → onComplete(上报 audit,携带新 nonce)并清空 pending;
|
||
* 失败 → onFailure(本地 JSONL 留痕)并清空 pending;
|
||
* 无 pending / 已终结 / 已过期 → no-op(不误伤无关重启)。
|
||
*
|
||
* 版本比对语义:与 updater.probeHealth(round-45)一致——expectedVersion 由调用方显式
|
||
* 提供时才做严格版本比对(不匹配 → HEALTH_VERSION_MISMATCH 失败留痕);未提供时退化为
|
||
* 仅校验新网关接受新 nonce 且 HTTP 200(升级安装期内 sidecar 重启仍由旧 main 进程注入
|
||
* APS_APP_VERSION,此时 targetVersion 比对会误报,故不自动取 targetVersion 作比较值)。
|
||
*
|
||
* 状态机:
|
||
* (null) --setPending--> installing
|
||
* installing --markInstalled--> verifying
|
||
* installing --markFailed--> failed
|
||
* verifying --onSidecarRestarted(+verifyHealth 成功)--> verified(onComplete 后清空)
|
||
* verifying --onSidecarRestarted(+verifyHealth 失败)--> failed(onFailure 后清空)
|
||
* verifying --超过 maxPendingMs--> stale(清空,no-op)
|
||
* installing 期间收到 restarted 事件 → 延迟记录(deferredRestart),markInstalled 后立即重验证,
|
||
* 避免「升级安装期间 sidecar 重启」这个真实场景被漏掉。
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const UPGRADE_NONCE_EVENT_VERIFIED = 'upgrade.health-verified';
|
||
const UPGRADE_NONCE_EVENT_FAILED = 'upgrade.health-verify-failed';
|
||
|
||
/** 把任意错误归一化为 { code, message, details }(UpgradeError 保留 code/details)。 */
|
||
function normalizeUpgradeError(error) {
|
||
if (error && typeof error === 'object' && (error.code || error.message)) {
|
||
return {
|
||
code: error.code || 'UNKNOWN',
|
||
message: error.message || String(error),
|
||
details: error.details ?? null,
|
||
};
|
||
}
|
||
return { code: 'UNKNOWN', message: String(error), details: null };
|
||
}
|
||
|
||
/**
|
||
* 创建升级 nonce 续接协调器(单 pending 槽;桌面单实例,同时只有一个升级)。
|
||
*
|
||
* @param {object} options
|
||
* @param {Function} options.verifyHealth 必填:升级后健康检查,签名
|
||
* async ({ origin, nonce, expectedVersion, pending }) => health(失败抛错)。
|
||
* main 侧通常直接包 probeHealth:({ origin, nonce, expectedVersion }) =>
|
||
* probeHealth({ base: origin, expectedVersion, sidecarNonce: nonce })。
|
||
* @param {Function} [options.onComplete] async ({ pending, health, nonce }) => void
|
||
* 重验证成功回调(上报 audit,携带新 nonce);抛错不阻断状态机(已标记完成)。
|
||
* @param {Function} [options.onFailure] async ({ pending, error }) => void
|
||
* 重验证失败回调(本地日志/审计留痕);抛错不阻断状态机(已标记失败)。
|
||
* @param {object} [options.logger] console 兼容 logger(默认 console)。
|
||
* @param {number} [options.maxPendingMs] pending 从 startedAt 起的有效期,超过后
|
||
* restarted 事件视为过期 no-op(默认 10 分钟,覆盖 NSIS 安装 + 应用重启耗时)。
|
||
* @param {Function} [options.now] 时钟注入(默认 Date.now),便于测试。
|
||
* @returns {{
|
||
* setPending, getPending, getLastResult, clearPending,
|
||
* markInstalled, markFailed, onSidecarRestarted,
|
||
* }}
|
||
*/
|
||
function createUpgradeNonceCoordinator(options = {}) {
|
||
const {
|
||
verifyHealth,
|
||
onComplete = async () => {},
|
||
onFailure = async () => {},
|
||
logger = console,
|
||
maxPendingMs = 10 * 60 * 1000,
|
||
now = Date.now,
|
||
statePath = null,
|
||
} = options;
|
||
if (typeof verifyHealth !== 'function') {
|
||
throw new TypeError('createUpgradeNonceCoordinator: verifyHealth 必须为函数');
|
||
}
|
||
|
||
let pending = null;
|
||
let lastResult = null;
|
||
|
||
// ---- 跨进程持久化(round-48):pending 升级状态落盘,应用重启后可续接 ----
|
||
function writeState() {
|
||
if (!statePath) return;
|
||
try {
|
||
const dir = path.dirname(statePath);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const tmp = statePath + '.tmp';
|
||
fs.writeFileSync(tmp, JSON.stringify({ pending, lastResult, updatedAt: new Date(now()).toISOString() }), 'utf8');
|
||
fs.renameSync(tmp, statePath);
|
||
} catch (error) {
|
||
logger.warn?.('[upgrade-nonce] 持久化升级状态失败:', error.message);
|
||
}
|
||
}
|
||
|
||
function readState() {
|
||
if (!statePath) return null;
|
||
try {
|
||
const raw = fs.readFileSync(statePath, 'utf8');
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 工厂初始化:恢复未过期的 pending(应用重启后 status 视为 verifying,等待新 sidecar 补验)。 */
|
||
function restoreState() {
|
||
const saved = readState();
|
||
if (!saved || !saved.pending) return;
|
||
const restored = saved.pending;
|
||
const startedAtMs = Date.parse(restored.startedAt || '');
|
||
const expired = Number.isFinite(startedAtMs) && (now() - startedAtMs) > maxPendingMs;
|
||
if (expired) {
|
||
logger.warn?.('[upgrade-nonce] 恢复时发现升级状态已过期,丢弃');
|
||
return;
|
||
}
|
||
if (restored.status === 'installing' || restored.status === 'verifying') {
|
||
pending = { ...restored, status: 'verifying', deferredRestart: null };
|
||
lastResult = saved.lastResult || null;
|
||
logger.info?.('[upgrade-nonce] 已恢复待验证升级:', pending.id, '等待新 sidecar 补验');
|
||
}
|
||
}
|
||
restoreState();
|
||
|
||
/** 进行中的 pending(安装中/待验证)——已终结的 pending 可被新升级替换。 */
|
||
function isActive(target) {
|
||
return Boolean(target && (target.status === 'installing' || target.status === 'verifying'));
|
||
}
|
||
|
||
function settle(status, extra = {}) {
|
||
const snapshot = {
|
||
...pending,
|
||
...extra,
|
||
status,
|
||
settledAt: new Date(now()).toISOString(),
|
||
};
|
||
lastResult = snapshot;
|
||
pending = null;
|
||
writeState();
|
||
return snapshot;
|
||
}
|
||
|
||
/**
|
||
* 登记待验证升级(apply-update 前调用)。
|
||
* @param {object} upgrade 至少含 id;可选 currentVersion / targetVersion /
|
||
* expectedVersion / healthBase / audit(审计凭据快照)/ startedAt。
|
||
* @returns {object|null} 登记的 pending;已有进行中 pending 时返回 null(不覆盖)。
|
||
*/
|
||
function setPending(upgrade = {}) {
|
||
if (isActive(pending)) {
|
||
logger.warn?.('[upgrade-nonce] 已有进行中的升级待验证,忽略新 setPending:', upgrade.id);
|
||
return null;
|
||
}
|
||
pending = {
|
||
status: 'installing',
|
||
deferredRestart: null,
|
||
preliminaryHealth: null,
|
||
appliedNonce: null,
|
||
...upgrade,
|
||
startedAt: upgrade.startedAt || new Date(now()).toISOString(),
|
||
};
|
||
lastResult = null;
|
||
writeState();
|
||
return pending;
|
||
}
|
||
|
||
/** 当前 pending(可能为已终结的 failed,供观测;下个 setPending 会替换)。 */
|
||
function getPending() {
|
||
return pending;
|
||
}
|
||
|
||
/** 最近一次终结结果(verified/failed/stale 快照),用于审计/日志。 */
|
||
function getLastResult() {
|
||
return lastResult;
|
||
}
|
||
|
||
/** 清空 pending(id 缺省时强制清空;指定时仅清空匹配 id)。 */
|
||
function clearPending(id) {
|
||
if (!pending) return false;
|
||
if (id === undefined || pending.id === id) {
|
||
pending = null;
|
||
writeState();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* applyUpdate 成功返回后调用:installing → verifying。
|
||
* applyUpdate 自带的健康检查(旧/当前 nonce)视为初步校验;真正的最终校验
|
||
* 等 sidecar 重启(新 nonce)由 onSidecarRestarted 驱动。若安装期间已收到过
|
||
* restarted 事件(deferredRestart),这里会立即用新 nonce 补做最终校验。
|
||
*
|
||
* @param {object} args
|
||
* @param {string} [args.id] pending id(缺省对应当前 pending)
|
||
* @param {object} [args.report] applyUpdate 返回值
|
||
* @param {boolean} [args.deferFinalVerify] true(默认)保留 pending 等待 restarted;
|
||
* 无 sidecar 环境(如 dev)传 false 直接按初步校验完成并清空。
|
||
* @returns {Promise<object>} pending 或终结结果
|
||
*/
|
||
async function markInstalled({ id, report = {}, deferFinalVerify = true } = {}) {
|
||
if (!pending || (id !== undefined && pending.id !== id) || pending.status !== 'installing') {
|
||
return pending;
|
||
}
|
||
if (report && report.ok === false) {
|
||
return markFailed({ id, error: new Error('applyUpdate 返回 ok=false') });
|
||
}
|
||
pending.status = 'verifying';
|
||
pending.targetVersion = report.targetVersion || pending.targetVersion || null;
|
||
pending.appliedNonce = report.nonce || pending.appliedNonce || null;
|
||
pending.preliminaryHealth = report.health || null;
|
||
// 注意:不做 expectedVersion ← targetVersion 自动回填——旧 main 进程重启的 sidecar
|
||
// 仍上报旧 APS_APP_VERSION,自动比对会误报失败;版本比对由调用方显式提供。
|
||
const deferred = pending.deferredRestart;
|
||
pending.deferredRestart = null;
|
||
if (deferred && deferFinalVerify) {
|
||
return onSidecarRestarted(deferred);
|
||
}
|
||
if (!deferFinalVerify) {
|
||
const result = settle('verified', {
|
||
health: pending.preliminaryHealth || null,
|
||
nonce: pending.appliedNonce || null,
|
||
verifiedVia: 'preliminary',
|
||
});
|
||
return result;
|
||
}
|
||
writeState();
|
||
return pending;
|
||
}
|
||
|
||
/**
|
||
* applyUpdate 抛错(含升级失败自动回滚)后调用:installing → failed。
|
||
* 仅记录状态供观测(renderer 已收到错误);不调用 onFailure(那是 restarted
|
||
* 重验证失败路径的语义),也不清空——下个 setPending 会替换。
|
||
*/
|
||
function markFailed({ id, error }) {
|
||
if (!pending || (id !== undefined && pending.id !== id)) return pending;
|
||
pending.status = 'failed';
|
||
pending.error = normalizeUpgradeError(error);
|
||
writeState();
|
||
return pending;
|
||
}
|
||
|
||
/**
|
||
* 核心入口:sidecar 'restarted' 事件后调用,用新 nonce 重跑升级后健康检查。
|
||
* @param {object} event { origin, nonce, at }
|
||
* @returns {Promise<{handled:boolean, ok?:boolean, reason?:string, health?, nonce?, error?, pending?}>}
|
||
*/
|
||
async function onSidecarRestarted({ origin, nonce, at } = {}) {
|
||
if (!pending) return { handled: false, reason: 'no-pending' };
|
||
if (pending.status === 'installing') {
|
||
// 安装尚在进行(applyUpdate 未返回):只记录,等 markInstalled 后补做最终校验
|
||
pending.deferredRestart = { origin, nonce, at: at || new Date(now()).toISOString() };
|
||
return { handled: false, reason: 'installing-deferred' };
|
||
}
|
||
if (pending.status !== 'verifying') {
|
||
return { handled: false, reason: 'already-settled', status: pending.status };
|
||
}
|
||
const elapsed = now() - Date.parse(pending.startedAt);
|
||
if (Number.isFinite(elapsed) && elapsed > maxPendingMs) {
|
||
logger.warn?.('[upgrade-nonce] pending 升级已过期(', elapsed, 'ms >', maxPendingMs, 'ms),跳过续接');
|
||
const stale = settle('stale', { reason: 'expired' });
|
||
return { handled: false, reason: 'stale', pending: stale };
|
||
}
|
||
return handleSidecarRestarted({ origin, nonce, at: at || new Date(now()).toISOString() });
|
||
}
|
||
|
||
async function handleSidecarRestarted({ origin, nonce, at }) {
|
||
const target = pending;
|
||
target.verifyAttempt = { origin, nonce, at };
|
||
try {
|
||
const health = await verifyHealth({
|
||
origin,
|
||
nonce,
|
||
expectedVersion: target.expectedVersion || null,
|
||
pending: target,
|
||
});
|
||
target.status = 'verified';
|
||
target.health = health;
|
||
target.nonce = nonce; // 本次续接实际使用的新 nonce(可追溯)
|
||
target.verifiedAt = at;
|
||
try {
|
||
await onComplete({ pending: target, health, nonce });
|
||
} catch (error) {
|
||
logger.error?.('[upgrade-nonce] onComplete 失败(升级已标记完成):', error);
|
||
}
|
||
const result = settle('verified', { health, nonce, verifiedVia: 'sidecar-restart' });
|
||
return { handled: true, ok: true, health, nonce, pending: result };
|
||
} catch (error) {
|
||
const normalized = normalizeUpgradeError(error);
|
||
target.status = 'failed';
|
||
target.error = normalized;
|
||
target.nonce = nonce; // 记录尝试使用的 nonce(可追溯)
|
||
target.verifiedAt = at;
|
||
try {
|
||
await onFailure({ pending: target, error: normalized });
|
||
} catch (failureError) {
|
||
logger.error?.('[upgrade-nonce] onFailure 失败(状态仍标记失败):', failureError);
|
||
}
|
||
const result = settle('failed', { error: normalized, nonce, verifiedVia: 'sidecar-restart' });
|
||
logger.error?.('[upgrade-nonce] sidecar 重启后升级健康检查失败(新 nonce 续接):', normalized.message);
|
||
return { handled: true, ok: false, error: normalized, nonce, pending: result };
|
||
}
|
||
}
|
||
|
||
return {
|
||
setPending,
|
||
getPending,
|
||
getLastResult,
|
||
clearPending,
|
||
markInstalled,
|
||
markFailed,
|
||
onSidecarRestarted,
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
createUpgradeNonceCoordinator,
|
||
normalizeUpgradeError,
|
||
UPGRADE_NONCE_EVENT_VERIFIED,
|
||
UPGRADE_NONCE_EVENT_FAILED,
|
||
};
|