From eaa1a1553a9087a45b89f6cbcbb47f22be87afb2 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 13:05:25 +0800 Subject: [PATCH 1/2] refactor(harmony): drive the native round through the shared round engine SyncCoordinator.ts's hand-written round (runConfiguredRound, the endpoint query walk, the two-budget performAttempts loop, runWithinDeadline and the result mapping) becomes a loop over NativePatchCore.stepRound (new synchronous NAPI binding over flowcore::StepRoundJson) that runs the commands it returns: the breadcrumb, the launch env, HTTP, the installed check, downloads and the commit. HTTP and downloads stay asynchronous ArkTS; each engine step is a cheap synchronous call, so nothing blocks the single thread. The host's post-wait check compares configs by C1 equivalence (was string equality), matching Android and iOS. Tests: harmonySyncRound.test.ts replays the round-engine vectors against the adapter (the vectors' commands stand in for the engine, the IO answers come from the next vector event) and checks every event the adapter sends back plus the mapped result; it replaces the query-walk and attempt-loop tests of the deleted functions. nativeHostApi.test.ts drives the host API through a scripted engine instead of the removed functions. Co-Authored-By: Claude Opus 5.5 --- docs/NATIVE_CHECKUPDATE_DESIGN.md | 4 +- harmony/pushy/src/main/cpp/pushy.cpp | 14 + harmony/pushy/src/main/ets/NativePatchCore.ts | 5 + harmony/pushy/src/main/ets/SyncCoordinator.ts | 528 ++++++++---------- src/__tests__/harmonySyncAttempts.test.ts | 118 ---- src/__tests__/harmonySyncQuery.test.ts | 97 ---- src/__tests__/harmonySyncRound.test.ts | 200 +++++++ src/__tests__/nativeHostApi.test.ts | 146 ++++- 8 files changed, 581 insertions(+), 531 deletions(-) delete mode 100644 src/__tests__/harmonySyncAttempts.test.ts delete mode 100644 src/__tests__/harmonySyncQuery.test.ts create mode 100644 src/__tests__/harmonySyncRound.test.ts diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index 20b6c67e..cf051bdb 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -645,4 +645,6 @@ full 超时整体失败)、激活判定(`activate || crashHold || forceActiv 进度:1、2 已合入(#673、#674)。iOS 已接入:`RCTPushyOrchestrator.mm` 的 `runOnce` 驱动引擎, 命令由 `perform:` / `download:` / `commit:` 执行,宿主取消判定改用 `HostCancellation`;提交败于配置 -变更时由适配器按配置代数回报 `reason: "config_changed"`。 +变更时由适配器按配置代数回报 `reason: "config_changed"`。鸿蒙已接入:`SyncCoordinator.ts` 的 `runOnce` 经 `NativePatchCore.stepRound`(NAPI,同步)驱动 +引擎,HTTP 与下载仍是 ArkTS 异步;宿主取消判定改为 C1 等价。`harmonySyncRound.test.ts` 以轨迹向量为脚本 +回放适配器,逐步核对它回给引擎的事件。 diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index d0677063..ae8306eb 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -3,6 +3,7 @@ #include "flow_json.h" #include "native_config.h" #include "native_round.h" +#include "round_engine.h" #include #include @@ -1333,6 +1334,18 @@ static napi_value FlowPrepareRound(napi_env env, napi_callback_info info) { env, flowcore::PrepareRound(text[0], ParseFlowObject(text[1]), sample)); } +// stepRound(stateJson?, eventJson) — one step of the round driver +// (round_engine.h); stateJson undefined/null starts fresh. Synchronous and +// cheap: the round's IO stays in ArkTS. +static napi_value FlowStepRound(napi_env env, napi_callback_info info) { + napi_value args[2] = {nullptr, nullptr}; + std::string text[2]; + if (!GetFlowArgs(env, info, "stepRound", 2, 0b11, args, text)) { + return nullptr; + } + return NewString(env, flowcore::StepRoundJson(text[0], text[1])); +} + // nextQueryStep(stateJson, responseText?) — responseText undefined/null for // a failed (or no) request. static napi_value FlowNextQueryStep(napi_env env, napi_callback_info info) { @@ -1448,6 +1461,7 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "getSupportedDiffVersion", Guarded) || !ExportFunction(env, exports, "prepareRound", Guarded) || !ExportFunction(env, exports, "nextQueryStep", Guarded) || + !ExportFunction(env, exports, "stepRound", Guarded) || !ExportFunction(env, exports, "handleResponse", Guarded) || !ExportFunction(env, exports, "configsEquivalent", Guarded) || !ExportFunction(env, exports, "runnableConfig", Guarded) || diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 93f76153..008fb685 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -163,6 +163,11 @@ interface NativePatchCoreBindings { stateJson: string, responseText: string | undefined, ): string | undefined; + /** + * 轮次驱动的一步(cpp/update_flow_core/round_engine.h):返回 + * {"state", "commands"};stateJson 为 undefined 表示开始新一轮。 + */ + stepRound(stateJson: string | undefined, eventJson: string): string; /** 决策(已按本机规划的 attempts)、要持久化的 hashInfo 与响应缓存文本 */ handleResponse( responseText: string, diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index 45254753..92bccc63 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -5,21 +5,14 @@ import NativePatchCore from './NativePatchCore'; import type { UpdateContext } from './UpdateContext'; import { BundlePreparationRound, bundlePreparationResult } from './BundlePreparationResult'; import type { BundlePreparationResult } from './BundlePreparationResult'; -import { isSafePathComponent } from './PathUtils'; -import { STATUS_NONE } from './Texts'; import { monotonicNowMs } from './MonotonicClock'; -import { - ERROR_DELTA_FAILED, - ERROR_DOWNLOAD_FAILED, - PushyError, - createPushyError, - getErrorMessage, -} from './ErrorCodes'; +import { PushyError, getErrorMessage } from './ErrorCodes'; // 原生冷启动检测(NATIVE_CHECKUPDATE_DESIGN §10):每进程一次,getBundleUrl // 后延迟数秒运行,完全不依赖 app bundle——坏热更把 JS 砸挂后,下次启动仍能 -// 拉到修复版。决策全部来自 cpp/update_flow_core(经 NativePatchCore 的 -// NAPI 面),本文件只是 IO 胶水。失败静默且有界:每次启动至多一轮,无重试 +// 拉到修复版。轮次本身由共享引擎驱动(cpp/update_flow_core/round_engine.h, +// 经 NativePatchCore.stepRound,NATIVE_CHECKUPDATE_DESIGN §12),本文件只 +// 执行它要求的 IO。失败静默且有界:每次启动至多一轮,无重试 // 风暴,不拉黑版本。 // 鸿蒙的 debug 门控由触发点天然承担:dev 走 MetroJSBundleProvider, // PushyFileJSBundleProvider.getBundleUrl 不会被调用。 @@ -32,13 +25,7 @@ export const KEY_RESP_CACHE = 'nativeCheckResp'; // (errorManager 的同步回调驱动不了 ArkTS 异步网络 IO),续传 + 零延迟 // 标记是收敛砖机的全部机制。 export const KEY_ROUND_INCOMPLETE = 'nativeCheckIncomplete'; -const REQUEST_TIMEOUT_MS = 10000; -const REQUEST_CALL_TIMEOUT_MS = 15000; -const MAX_QUERY_HTTP_ATTEMPTS = 8; const START_DELAY_MS = 5000; -const DOWNLOAD_PHASE_TIMEOUT_MS = 10 * 60 * 1000; -const DOWNLOAD_TYPE_DIFF = 'diff'; -const DOWNLOAD_TYPE_PDIFF = 'pdiff'; // 本次启动的事实,交给共享内核(cpp/update_flow_core/native_round.h)。 interface RoundEnv { @@ -51,32 +38,49 @@ interface RoundEnv { bundleHash: string; } -interface PreparedRound { - body: string; - query: Object; +// 引擎的事件与命令(字段含义见 round_engine.h)。 +interface RoundEvent { + type?: string; + now?: number; + config?: string; + resetGeneration?: number; + configGeneration?: number; + sample?: number; + deadline?: number; + env?: RoundEnv; + text?: string; + at?: number; + installed?: boolean; + ok?: boolean; + code?: string; + timedOut?: boolean; + reason?: string; + error?: boolean; } -interface QueryStep { +interface RoundCommand { + type: string; url?: string; post?: boolean; - found?: boolean; -} - -interface DecisionAttempt { - type?: string; - urls?: string[]; -} - -interface Decision { - action?: string; - reason?: string; + body?: string; + timeoutMs?: number; + callTimeoutMs?: number; hash?: string; - attempts?: DecisionAttempt[]; + strategy?: string; + originHash?: string; + deadline?: number; + hashInfo?: string; activate?: boolean; - // 要与版本一起持久化的元信息(JS 侧 setLocalHashInfo;forceBootRescue 由内核标记)。 - hashInfo?: Object; - // 供 JS 复用的响应缓存文本(§10.3)。 cache?: string; + resetGeneration?: number; + status?: string; + reason?: string; + activated?: boolean; +} + +interface RoundStep { + state: Object; + commands: RoundCommand[]; } let scheduled = false; @@ -173,8 +177,10 @@ export async function prepareBundleNative( } result = settled; } + // 与引擎的 HostCancellation 同一规则:三方 C1 等价且配置代数未变。 if (roundConfigGeneration !== context.getNativeConfigGeneration() - || configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { + || !NativePatchCore.configsEquivalent(configJson, roundConfigJson) + || !context.isStoredNativeConfig(configJson)) { return bundlePreparationResult('cancelled', 'config_changed'); } if (roundGeneration !== context.getResetGeneration()) { @@ -231,58 +237,109 @@ async function runOnce( context: UpdateContext, launchRolledBackVersion: string, ): Promise { - // 在任何 IO 之前采样:resetToPackagedBundle 会递增它,本轮运行期间发生的 - // reset 必须赢过本轮的决策。 + // 在任何 IO 之前采样:resetToPackagedBundle 与配置替换都会递增 reset 代数, + // 本轮运行期间发生的 reset 必须赢过本轮的决策。 const resetGeneration = context.getResetGeneration(); + const configGeneration = context.getNativeConfigGeneration(); roundGeneration = resetGeneration; - roundConfigGeneration = context.getNativeConfigGeneration(); + roundConfigGeneration = configGeneration; roundResult = bundlePreparationResult('failed', 'check_failed'); - const read = readNativeConfig(context); - roundConfigJson = read.json; - if (read.rejection !== undefined) { - roundResult = read.rejection; - return; - } - const configJson = read.json; - // 从这里起本轮开始做真实工作:留下面包屑,死于轮中时下次启动零延迟续传。 - logger.info( - TAG, - `round started (resume=${!!context.getKv(KEY_ROUND_INCOMPLETE)})`, - ); - // 面包屑只是"下次零延迟"的优化:落盘失败不能阻止救援轮次本身。 - try { - await context.setKv(KEY_ROUND_INCOMPLETE, '1'); - } catch (e) { - logger.warn(TAG, `cannot persist round marker: ${getErrorMessage(e)}`); - } + const configJson = context.getKv(KEY_CONFIG); + roundConfigJson = configJson; + + let event: RoundEvent = { + type: 'start', + config: configJson ?? '', + resetGeneration, + configGeneration, + sample: Math.random(), + deadline: 0, + }; + let state: string | undefined = undefined; + let marked = false; try { - await runConfiguredRound( - context, - launchRolledBackVersion, - resetGeneration, - configJson, - ); + for (;;) { + event.now = monotonicNowMs(); + const step = JSON.parse( + NativePatchCore.stepRound(state, JSON.stringify(event)), + ) as RoundStep; + state = JSON.stringify(step.state); + event = {}; + for (const command of step.commands) { + if (command.type === 'markIncomplete') { + // 从这里起本轮开始做真实工作:留下面包屑,死于轮中时下次启动零延迟续传。 + marked = true; + logger.info( + TAG, + `round started (resume=${!!context.getKv(KEY_ROUND_INCOMPLETE)})`, + ); + // 面包屑只是"下次零延迟"的优化:落盘失败不能阻止救援轮次本身。 + try { + await context.setKv(KEY_ROUND_INCOMPLETE, '1'); + } catch (e) { + logger.warn(TAG, `cannot persist round marker: ${getErrorMessage(e)}`); + } + } else if (command.type === 'finish') { + finishRound(command); + return; + } else { + event = await perform(context, launchRolledBackVersion, command, configGeneration); + } + } + } } finally { - try { - await context.removeKv(KEY_ROUND_INCOMPLETE); - } catch (e) { - // 残留标记只意味着下次启动少等 5 秒。 - logger.warn(TAG, `cannot clear round marker: ${getErrorMessage(e)}`); + if (marked) { + try { + await context.removeKv(KEY_ROUND_INCOMPLETE); + } catch (e) { + // 残留标记只意味着下次启动少等 5 秒。 + logger.warn(TAG, `cannot clear round marker: ${getErrorMessage(e)}`); + } } } } -async function runConfiguredRound( +// 执行引擎的一条命令,返回回应它的事件。 +async function perform( context: UpdateContext, launchRolledBackVersion: string, - resetGeneration: number, - configJson: string, -): Promise { - const currentVersion = context.getCurrentVersion(); - const env: RoundEnv = { + command: RoundCommand, + configGeneration: number, +): Promise { + switch (command.type) { + case 'collectEnv': + return { type: 'env', env: await launchEnv(context, launchRolledBackVersion) }; + case 'http': { + const text = await httpRequest( + command.url ?? '', + command.post ? command.body ?? '' : undefined, + command.timeoutMs ?? 0, + command.callTimeoutMs ?? 0, + ); + return text === undefined + ? { type: 'http' } + : { type: 'http', text, at: Date.now() / 1000 }; + } + case 'checkInstalled': + return { type: 'installed', installed: context.hasDownloadedVersion(command.hash ?? '') }; + case 'download': + return download(context, command); + case 'commit': + return commit(context, command, configGeneration); + default: + // 本适配器不认识的命令:引擎会以 internal_error 结束本轮。 + return { type: command.type }; + } +} + +async function launchEnv( + context: UpdateContext, + launchRolledBackVersion: string, +): Promise { + return { packageVersion: context.getPackageVersion(), // 与 JS 常量同形(无版本时为 ''),JS 才能复用这次请求的响应缓存。 - currentVersion, + currentVersion: context.getCurrentVersion(), uuid: context.getKv('uuid') ?? '', // getConstants consumes the persisted rollback marker during startup; use // the launch-path snapshot captured before that happens. @@ -293,122 +350,107 @@ async function runConfiguredRound( os: `harmony ${deviceInfo.osFullName}`, bundleHash: await context.getBundleHash(), }; - const envJson = JSON.stringify(env); - const preparedJson = NativePatchCore.prepareRound(configJson, envJson, Math.random()); - if (!preparedJson) { - roundResult = bundlePreparationResult('failed', 'invalid_request'); - return; - } - const prepared = JSON.parse(preparedJson) as PreparedRound; - const responseText = await runQueryRequest( - JSON.stringify(prepared.query), - prepared.body, - ); - if (!responseText) { - logger.warn(TAG, 'no endpoint reachable, giving up until next launch'); - return; - } - // 规划好的 attempts、hashInfo 与响应缓存(时间戳取响应到达时)都来自共享内核。 - const decisionJson = NativePatchCore.handleResponse( - responseText, - configJson, - envJson, - prepared.body, - Math.floor(Date.now() / 1000), - ); - if (!decisionJson) { - roundResult = bundlePreparationResult('failed', 'invalid_response'); - return; - } - const decision = JSON.parse(decisionJson) as Decision; - const responseCacheJson = decision.cache ?? ''; - // Commits the round's persistent outcome (see commitSyncResult) and maps - // it to the round result; a reset during the round wins over `result`. - const commit = async ( - hash: string, - hashInfoJson: string, - activate: boolean, - result: BundlePreparationResult, - ): Promise => { - const committed = await context.commitSyncResult( - resetGeneration, - hash, - hashInfoJson, - activate, - responseCacheJson, - ); - roundResult = committed - ? result - : bundlePreparationResult('cancelled', 'reset'); - return committed; - }; - - if (decision.action !== 'download') { - await commit( - '', - '', - false, - bundlePreparationResult(STATUS_NONE, decision.reason ?? ''), - ); - logger.info(TAG, `nothing to do (${decision.reason ?? ''})`); - return; - } - const hash = decision.hash ?? ''; - if (!isSafePathComponent(hash)) { - logger.warn(TAG, 'decision carries an unsafe hash, ignoring'); - roundResult = bundlePreparationResult('failed', 'invalid_response'); - return; - } +} - let downloaded = context.hasDownloadedVersion(hash); - if (downloaded) { - logger.info(TAG, `${hash} already installed, skipping download`); - } else { - logger.info(TAG, `downloading ${hash}`); - downloaded = await performAttempts( - context, - decision.attempts ?? [], - hash, - currentVersion, - ); - } - if (!downloaded) { - logger.warn(TAG, `all download attempts for ${hash} failed`); - await commit('', '', false, bundlePreparationResult('failed', 'download_failed')); - return; +// 一次下载尝试,以引擎给的 deadline 为界:超时即 timedOut,失败带错误码 +// (PATCH_FAILED 由引擎决定换策略而不换镜像)。 +async function download( + context: UpdateContext, + command: RoundCommand, +): Promise { + const strategy = command.strategy ?? ''; + const url = command.url ?? ''; + const hash = command.hash ?? ''; + const deadline = command.deadline ?? 0; + let timedOut = false; + let timer = 0; + const expired = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve(); + }, Math.max(0, deadline - monotonicNowMs())); + }); + // This bounds queueing, HTTP, decompression and native hpatch work from the + // round's perspective. The serialized task may still finish later, but it + // can no longer hold the round. + const start = (): Promise => { + if (strategy === 'diff') { + return context.downloadPatchFromPpk(url, hash, command.originHash ?? '', deadline); + } + if (strategy === 'pdiff') { + return context.downloadPatchFromPackage(url, hash, deadline); + } + return context.downloadFullUpdate(url, hash, deadline); + }; + try { + await Promise.race([start(), expired]); + if (timedOut) { + logger.warn(TAG, `${strategy} attempt timed out`); + return { type: 'download', timedOut: true }; + } + return { type: 'download', ok: true }; + } catch (e) { + logger.warn(TAG, `${strategy} attempt failed: ${getErrorMessage(e)}`); + // 无错误码的失败(网络异常等)可换镜像重试。 + return e instanceof PushyError + ? { type: 'download', ok: false, code: e.code } + : { type: 'download', ok: false }; + } finally { + clearTimeout(timer); } +} - const hashInfoJson = JSON.stringify(decision.hashInfo); - - // 版本元信息、激活与响应缓存一次性原子提交(见 commitSyncResult); - // 缓存只在原生文件/状态工作结束后公开,避免 JS 观察到响应后并发下载。 - // 静默策略、或服务端按版本标记的 forceBoot(远程覆盖,救砖指令)才激活。 - const activate = decision.activate === true; - let committed = false; +// 版本元信息、激活与响应缓存一次性原子提交(见 commitSyncResult)。配置替换 +// 也会递增 reset 代数,提交因此被拒;配置代数用来区分是哪一种。 +async function commit( + context: UpdateContext, + command: RoundCommand, + configGeneration: number, +): Promise { try { - committed = await commit( - hash, - hashInfoJson, - activate, - bundlePreparationResult('downloaded', '', hash, activate), + const committed = await context.commitSyncResult( + command.resetGeneration ?? -1, + command.hash ?? '', + command.hashInfo ?? '', + command.activate === true, + command.cache ?? '', ); + if (!committed && context.getNativeConfigGeneration() !== configGeneration) { + return { type: 'committed', ok: false, reason: 'config_changed' }; + } + return { type: 'committed', ok: committed }; } catch (e) { logger.error(TAG, `commit failed: ${getErrorMessage(e)}`); - roundResult = bundlePreparationResult('failed', 'commit_failed'); - return; + return { type: 'committed', ok: false, error: true }; } - if (!committed) { - logger.warn(TAG, 'reset during round, dropping result'); - } else if (activate) { - logger.info(TAG, `downloaded ${hash} and set for next launch`); - } else { - logger.info(TAG, `downloaded ${hash}, activation left to JS`); +} + +function finishRound(finish: RoundCommand): void { + const status = finish.status ?? 'failed'; + const reason = finish.reason ?? ''; + if (status === 'downloaded') { + const hash = finish.hash ?? ''; + const activated = finish.activated === true; + logger.info( + TAG, + activated + ? `downloaded ${hash} and set for next launch` + : `downloaded ${hash}, activation left to JS`, + ); + roundResult = bundlePreparationResult(status, reason, hash, activated); + return; } + logger.info(TAG, `${status} (${reason})`); + roundResult = bundlePreparationResult(status, reason); } +// The engine's per-request bounds: timeoutMs for connect/read, callTimeoutMs +// for the whole call. async function httpRequest( url: string, - postBody?: string, + postBody: string | undefined, + timeoutMs: number, + callTimeoutMs: number, ): Promise { const client = http.createHttp(); let callTimer: number | null = null; @@ -423,14 +465,14 @@ async function httpRequest( : http.RequestMethod.GET, header, extraData: postBody, - connectTimeout: REQUEST_TIMEOUT_MS, - readTimeout: REQUEST_TIMEOUT_MS, + connectTimeout: Math.max(1, Math.floor(timeoutMs)), + readTimeout: Math.max(1, Math.floor(timeoutMs)), expectDataType: http.HttpDataType.STRING, }); const timeoutPromise = new Promise((_, reject) => { callTimer = setTimeout(() => { - reject(Error(`HTTP request exceeded ${REQUEST_CALL_TIMEOUT_MS}ms`)); - }, REQUEST_CALL_TIMEOUT_MS); + reject(Error(`HTTP request exceeded ${callTimeoutMs}ms`)); + }, Math.max(0, callTimeoutMs)); }); const response = await Promise.race([requestPromise, timeoutPromise]); if ( @@ -450,123 +492,3 @@ async function httpRequest( client.destroy(); } } - -// 顺序回退(§5.1):按共享内核给出的下一步逐个请求,单请求超时 -// (NativePatchCore.nextQueryStep:候选序、queryUrls 发现、远程端点的 https -// 门槛、请求预算、响应 schema)。刻意不做 hedged race——该路径对延迟不敏感。 -async function runQueryRequest( - query: string, - body: string, -): Promise { - let state: string | undefined = query; - let text: string | undefined = undefined; - while (state !== undefined) { - state = NativePatchCore.nextQueryStep(state, text); - if (state === undefined) { - break; - } - const step = JSON.parse(state) as QueryStep; - if (step.found) { - return text; - } - if (!step.url) { - break; - } - text = await httpRequest(step.url, step.post ? body : undefined); - } - return undefined; -} - -async function runWithinDeadline( - start: () => Promise, - deadlineUptimeMs: number, -): Promise { - const remainingMs = deadlineUptimeMs - monotonicNowMs(); - if (remainingMs <= 0) { - throw createPushyError( - ERROR_DOWNLOAD_FAILED, - 'Download phase deadline expired before start', - ); - } - let deadlineTimer = 0; - const deadlinePromise = new Promise((_, reject) => { - deadlineTimer = setTimeout(() => { - reject( - createPushyError( - ERROR_DOWNLOAD_FAILED, - 'Download phase deadline exceeded', - ), - ); - }, remainingMs); - }); - try { - // This bounds queueing, HTTP, decompression and native hpatch work from - // the orchestrator's perspective. The serialized task may still finish - // later, but it can no longer prevent the response cache from settling. - await Promise.race([start(), deadlinePromise]); - } finally { - clearTimeout(deadlineTimer); - } -} - -async function performAttempts( - context: UpdateContext, - attempts: DecisionAttempt[], - hash: string, - originHash: string, -): Promise { - const incrementalDeadline = monotonicNowMs() + DOWNLOAD_PHASE_TIMEOUT_MS; - let fullDeadline = 0; - for (const attempt of attempts) { - const type = attempt.type ?? ''; - const isFullAttempt = - type !== DOWNLOAD_TYPE_DIFF && type !== DOWNLOAD_TYPE_PDIFF; - if (isFullAttempt && fullDeadline === 0) { - // Preserve a full 10min rescue budget even when diff/pdiff exhausted - // their own phase window. - fullDeadline = monotonicNowMs() + DOWNLOAD_PHASE_TIMEOUT_MS; - } - const deadline = isFullAttempt ? fullDeadline : incrementalDeadline; - for (const url of attempt.urls ?? []) { - if (monotonicNowMs() >= deadline) { - if (isFullAttempt) { - return false; - } - break; - } - try { - if (type === DOWNLOAD_TYPE_DIFF) { - await runWithinDeadline( - () => context.downloadPatchFromPpk(url, hash, originHash, deadline), - deadline, - ); - } else if (type === DOWNLOAD_TYPE_PDIFF) { - await runWithinDeadline( - () => context.downloadPatchFromPackage(url, hash, deadline), - deadline, - ); - } else { - await runWithinDeadline( - () => context.downloadFullUpdate(url, hash, deadline), - deadline, - ); - } - return true; - } catch (e) { - logger.warn(TAG, `${type} attempt failed: ${getErrorMessage(e)}`); - if (!isMirrorRetryable(e)) { - // The bytes arrived but could not be applied: every mirror serves - // the same artifact, so move on to the next strategy instead of - // re-downloading it (update_flow_core IsMirrorRetryableCode, JS - // client.ts isMirrorRetryableCode). - break; - } - } - } - } - return false; -} - -function isMirrorRetryable(error: unknown): boolean { - return !(error instanceof PushyError && error.code === ERROR_DELTA_FAILED); -} diff --git a/src/__tests__/harmonySyncAttempts.test.ts b/src/__tests__/harmonySyncAttempts.test.ts deleted file mode 100644 index dcf2dce9..00000000 --- a/src/__tests__/harmonySyncAttempts.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { - createPushyError, - ERROR_DELTA_FAILED, - ERROR_DOWNLOAD_FAILED, - PushyError, -} from '../../harmony/pushy/src/main/ets/ErrorCodes'; -import { evalHarmonyModule } from './nativeSources'; - -// The download strategy loop of the Harmony cold-start orchestrator -// (SyncCoordinator.performAttempts), evaluated from source with its platform -// imports stripped. A patch that downloaded but failed to apply is not -// retried on the other mirrors (update_flow_core IsMirrorRetryableCode). - -interface Attempt { - type: string; - urls: string[]; -} - -// failures: url -> error code (a coded PushyError) or any thrown value. -function harness(failures: Map) { - const calls: string[] = []; - const fail = async (url: string) => { - calls.push(url); - const failure = failures.get(url); - if (typeof failure === 'string') { - throw createPushyError(failure, `failed ${url}`); - } - if (failure !== undefined) { - throw failure; - } - }; - const context = { - downloadPatchFromPpk: (url: string) => fail(url), - downloadPatchFromPackage: (url: string) => fail(url), - downloadFullUpdate: (url: string) => fail(url), - }; - const performAttempts = evalHarmonyModule( - 'SyncCoordinator.ts', - 'performAttempts;', - { - PushyError, - ERROR_DELTA_FAILED, - ERROR_DOWNLOAD_FAILED, - createPushyError, - getErrorMessage: (error: unknown) => String(error), - logger: { info() {}, warn() {}, error() {} }, - monotonicNowMs: () => performance.now(), - setTimeout, - clearTimeout, - BundlePreparationRound: class {}, - bundlePreparationResult: () => ({}), - } - ) as ( - ctx: typeof context, - attempts: Attempt[], - hash: string, - originHash: string - ) => Promise; - return { - calls, - run: (attempts: Attempt[], originHash = 'origin') => - performAttempts(context, attempts, 'next', originHash), - }; -} - -describe('harmony download attempts', () => { - test('a patch that fails to apply skips the remaining mirrors', async () => { - const h = harness( - new Map([ - ['https://a/diff', ERROR_DELTA_FAILED], - ['https://b/diff', ERROR_DELTA_FAILED], - ]) - ); - const ok = await h.run([ - { type: 'diff', urls: ['https://a/diff', 'https://b/diff'] }, - { type: 'full', urls: ['https://a/full', 'https://b/full'] }, - ]); - expect(ok).toBe(true); - expect(h.calls).toEqual(['https://a/diff', 'https://a/full']); - }); - - test('a download failure still tries the next mirror', async () => { - const h = harness(new Map([['https://a/pdiff', ERROR_DOWNLOAD_FAILED]])); - const ok = await h.run([ - { type: 'pdiff', urls: ['https://a/pdiff', 'https://b/pdiff'] }, - { type: 'full', urls: ['https://a/full'] }, - ]); - expect(ok).toBe(true); - expect(h.calls).toEqual(['https://a/pdiff', 'https://b/pdiff']); - }); - - test('an uncoded error is retryable', async () => { - const h = harness( - new Map([['https://a/full', new Error('socket closed')]]) - ); - const ok = await h.run([ - { type: 'full', urls: ['https://a/full', 'https://b/full'] }, - ]); - expect(ok).toBe(true); - expect(h.calls).toEqual(['https://a/full', 'https://b/full']); - }); - - test('every strategy failing to apply reports failure', async () => { - const h = harness( - new Map([ - ['https://a/full', ERROR_DELTA_FAILED], - ['https://b/full', ERROR_DELTA_FAILED], - ]) - ); - expect( - await h.run([ - { type: 'full', urls: ['https://a/full', 'https://b/full'] }, - ]) - ).toBe(false); - expect(h.calls).toEqual(['https://a/full']); - }); -}); diff --git a/src/__tests__/harmonySyncQuery.test.ts b/src/__tests__/harmonySyncQuery.test.ts deleted file mode 100644 index 686a031c..00000000 --- a/src/__tests__/harmonySyncQuery.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { evalHarmonyModule } from './nativeSources'; - -// The endpoint walk of the Harmony cold-start orchestrator -// (SyncCoordinator.runQueryRequest), evaluated from source with its platform -// imports stripped and HTTP replaced by a scripted fake. The walk's policy — -// order, list discovery, https-only remote bases, the request budget, -// response validity — is the shared core's NextQueryStep, pinned by -// cpp/update_flow_core/tests/native-round-vectors.json; this checks that the -// glue follows the core's steps. - -const VALID = '{"upToDate":true}'; - -interface Step { - url?: string; - post?: boolean; - found?: boolean; -} - -function run( - steps: Array, - responses: Record -) { - const requests: Array<[string, string | undefined]> = []; - const calls: Array<[string, string | undefined]> = []; - let next = 0; - const runQueryRequest = evalHarmonyModule( - 'SyncCoordinator.ts', - 'httpRequest = fakeHttp;\nrunQueryRequest;', - { - logger: { info() {}, warn() {}, error() {} }, - BundlePreparationRound: class {}, - bundlePreparationResult: () => ({}), - NativePatchCore: { - nextQueryStep: (state: string, text: string | undefined) => { - calls.push([state, text]); - const step = steps[next++]; - return step === undefined - ? undefined - : JSON.stringify({ ...step, n: next }); - }, - }, - fakeHttp: async (url: string, body?: string) => { - requests.push([url, body]); - return responses[url]; - }, - } - ) as (query: string, body: string) => Promise; - return runQueryRequest('{"n":0}', 'BODY').then((result) => ({ - result, - requests, - calls, - })); -} - -describe('harmony query endpoint walk', () => { - test('issues each step, passes its text back and returns the found one', async () => { - const { result, requests, calls } = await run( - [ - { url: 'https://a/q', post: true }, - { url: 'https://l', post: false }, - { url: 'https://c/q', post: true }, - { found: true }, - ], - { 'https://l': '["https://c"]', 'https://c/q': VALID } - ); - expect(result).toBe(VALID); - expect(requests).toEqual([ - ['https://a/q', 'BODY'], - ['https://l', undefined], - ['https://c/q', 'BODY'], - ]); - // Each call carries the previous state and the last request's text. - expect(calls.map(([state]) => JSON.parse(state).n)).toEqual([0, 1, 2, 3]); - expect(calls.map(([, text]) => text)).toEqual([ - undefined, - undefined, - '["https://c"]', - VALID, - ]); - }); - - test('a step without a url ends the walk with no response', async () => { - const { result, requests } = await run( - [{ url: 'https://a/q', post: true }, {}], - { 'https://a/q': '{"error":"x"}' } - ); - expect(result).toBeUndefined(); - expect(requests).toEqual([['https://a/q', 'BODY']]); - }); - - test('a state the core rejects ends the walk', async () => { - const { result, requests } = await run([undefined], {}); - expect(result).toBeUndefined(); - expect(requests).toEqual([]); - }); -}); diff --git a/src/__tests__/harmonySyncRound.test.ts b/src/__tests__/harmonySyncRound.test.ts new file mode 100644 index 00000000..cb278864 --- /dev/null +++ b/src/__tests__/harmonySyncRound.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from 'bun:test'; +import vectorsFile from '../../cpp/update_flow_core/tests/round-engine-vectors.json'; +import { + BundlePreparationRound, + bundlePreparationResult, +} from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; +import { + createPushyError, + PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { evalHarmonyModule } from './nativeSources'; + +// The Harmony adapter of the shared round engine (SyncCoordinator.runOnce), +// evaluated from source against the rounds in +// cpp/update_flow_core/tests/round-engine-vectors.json: the vector's commands +// stand in for the engine, and the IO those commands ask for is faked from +// the vector's next event. The adapter must answer each command with the +// event the vector expects, and map the finish to its result. + +type Json = Record; +interface Step { + event: Json; + commands: Json[]; +} +interface Round { + name: string; + steps: Step[]; +} + +const fixtures = vectorsFile.fixtures as Record; +const withFixtures = (value: unknown): unknown => { + if (typeof value === 'string' && value.startsWith('@')) { + return fixtures[value.slice(1)]; + } + if (Array.isArray(value)) { + return value.map(withFixtures); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, withFixtures(entry)]) + ); + } + return value; +}; + +// What an adapter decides by itself; the rest of each event is the engine's +// bookkeeping (the clock, the response time). +const ANSWER_FIELDS = [ + 'type', + 'text', + 'installed', + 'ok', + 'code', + 'reason', + 'error', + 'activated', +]; +const answer = (event: Json) => + Object.fromEntries( + ANSWER_FIELDS.filter((key) => event[key] !== undefined).map((key) => [ + key, + event[key], + ]) + ); + +// Rounds whose events carry what Harmony never produces (a crash hold, a +// launch that waits on the round, a round deadline, a timed-out download, an +// activation the commit dropped) or that feed the engine protocol errors on +// purpose. +const replayable = (round: Round) => + round.steps[0].event.type === 'start' && + round.steps.every( + ({ event }, i) => + (i === 0 || event.type !== 'start') && + !event.crashHold && + !event.forceActivate && + !event.timedOut && + !event.deadline && + event.activated === undefined + ) && + !round.name.includes('internal_error'); + +async function replay(round: Round) { + const steps = round.steps.map((step) => withFixtures(step) as Step); + const start = steps[0].event; + const sent: Json[] = []; + let next = 0; + // The event that answers the command being run: the vector's next one. + const expected = () => steps[next]?.event ?? {}; + const download = async () => { + const event = expected(); + if (event.ok !== true) { + throw event.code === undefined + ? new Error('socket closed') + : createPushyError(String(event.code), 'fake'); + } + }; + const values = new Map([ + ['nativeConfig', String(start.config)], + ['uuid', 'u-1'], + ]); + const context = { + getKv: (key: string) => values.get(key), + setKv: async (key: string, value: string) => { + values.set(key, value); + }, + removeKv: async (key: string) => { + values.delete(key); + }, + getResetGeneration: () => start.resetGeneration, + // A commit lost to a config change: the config generation moved. + getNativeConfigGeneration: () => + expected().reason === 'config_changed' + ? Number(start.configGeneration) + 1 + : start.configGeneration, + getCurrentVersion: () => 'cur', + getPackageVersion: () => '1.0.0', + getBuildTime: () => '1700000000', + getBundleHash: async () => 'bh', + hasDownloadedVersion: () => expected().installed === true, + downloadPatchFromPpk: download, + downloadPatchFromPackage: download, + downloadFullUpdate: download, + commitSyncResult: async () => { + const event = expected(); + if (event.error) { + throw new Error('commit failed'); + } + return event.ok === true; + }, + }; + const runtime = evalHarmonyModule( + 'SyncCoordinator.ts', + 'httpRequest = fakeHttp;\n({ run: runOnce, result: () => roundResult });', + { + BundlePreparationRound, + bundlePreparationResult, + PushyError, + logger: { info() {}, warn() {}, error() {} }, + deviceInfo: { osFullName: 'test-os' }, + getErrorMessage: (error: unknown) => String(error), + monotonicNowMs: () => 0, + setTimeout: () => 0, + clearTimeout: () => {}, + fakeHttp: async () => expected().text as string | undefined, + NativePatchCore: { + stepRound: (_state: string | undefined, eventJson: string) => { + sent.push(JSON.parse(eventJson)); + const step = steps[next++]; + return JSON.stringify({ state: {}, commands: step.commands }); + }, + }, + } + ) as { + run: (context: unknown, rollback: string) => Promise; + result: () => unknown; + }; + await runtime.run(context, ''); + return { sent, steps, result: runtime.result(), values }; +} + +const rounds = (vectorsFile.rounds as Round[]).filter(replayable); + +describe('harmony round adapter', () => { + test('replays most of the engine vectors', () => { + expect(rounds.length).toBeGreaterThanOrEqual(20); + }); + + for (const round of rounds) { + test(round.name, async () => { + const { sent, steps, result, values } = await replay(round); + expect(sent.map(answer)).toEqual(steps.map(({ event }) => answer(event))); + // The start carries the round's facts as the adapter sampled them. + expect(sent[0]).toMatchObject({ + type: 'start', + config: steps[0].event.config, + resetGeneration: steps[0].event.resetGeneration, + configGeneration: steps[0].event.configGeneration, + }); + const finish = steps[steps.length - 1].commands.find( + (command) => command.type === 'finish' + ) as Json; + expect(result).toEqual( + finish.status === 'downloaded' + ? bundlePreparationResult( + 'downloaded', + '', + String(finish.hash), + finish.activated === true + ) + : bundlePreparationResult( + String(finish.status), + String(finish.reason) + ) + ); + // However the round ends, the breadcrumb does not survive it. + expect(values.has('nativeCheckIncomplete')).toBe(false); + }); + } +}); diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index 2bfca47e..de3df080 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -9,8 +9,11 @@ import { STATUS_NONE } from '../../harmony/pushy/src/main/ets/Texts'; import { evalHarmonyModule, harmonyConfigBindings } from './nativeSources'; // Evaluate the actual Harmony orchestrator in an isolated VM per test. Only -// platform imports, HTTP and download IO are substituted; entry points, -// scheduling, configuration gates, result mapping and reset checks are real. +// platform imports, HTTP, download IO and the round engine are substituted +// (the engine by a scripted stand-in: the real one is pinned by +// round-engine-vectors.json, and harmonySyncRound.test.ts replays it against +// this adapter); entry points, scheduling, configuration gates, the host +// wait and reset checks are real. interface Decision { action: string; @@ -19,6 +22,119 @@ interface Decision { activate?: boolean; } +// The round the engine would run for the harness's scenario: the config +// gate, one check, then the decision, the download and the commit. +function scriptedEngine(decision: () => Decision) { + return (stateJson: string | undefined, eventJson: string): string => { + const event = JSON.parse(eventJson); + const state = stateJson ? JSON.parse(stateJson) : {}; + const step = (next: object, ...commands: object[]) => + JSON.stringify({ state: { ...state, ...next }, commands }); + const finish = ( + status: string, + reason = '', + hash?: string, + activated?: boolean + ) => + step( + { phase: 'done' }, + { type: 'finish', status, reason, hash, activated } + ); + const commit = (hash: string, activate: boolean, result: object) => + step( + { phase: 'committed', result }, + { + type: 'commit', + hash, + hashInfo: hash ? '{}' : '', + activate, + cache: '{}', + resetGeneration: state.resetGeneration, + } + ); + switch (event.type) { + case 'start': { + const gate = JSON.parse( + harmonyConfigBindings.runnableConfig(event.config) + ); + if (gate.status) { + return finish(gate.status, gate.reason); + } + return JSON.stringify({ + state: { phase: 'env', resetGeneration: event.resetGeneration }, + commands: [{ type: 'markIncomplete' }, { type: 'collectEnv' }], + }); + } + case 'env': + return step( + { phase: 'http' }, + { + type: 'http', + url: 'https://check', + post: true, + body: '{}', + timeoutMs: 10000, + callTimeoutMs: 15000, + } + ); + case 'http': { + if (event.text === undefined) { + return finish('failed', 'check_failed'); + } + const d = decision(); + if (d.action !== 'download') { + return commit('', false, { + status: STATUS_NONE, + reason: d.reason ?? '', + }); + } + return step( + { phase: 'installed', decision: d }, + { type: 'checkInstalled', hash: d.hash } + ); + } + case 'installed': + case 'download': { + const d = state.decision as Decision; + const ready = event.type === 'installed' ? event.installed : event.ok; + if (ready) { + return commit(d.hash ?? '', d.activate === true, { + status: 'downloaded', + hash: d.hash, + activated: d.activate === true, + }); + } + if (event.type === 'installed') { + return step( + { phase: 'download' }, + { + type: 'download', + strategy: 'full', + url: 'https://cdn/full', + hash: d.hash, + deadline: 600000, + } + ); + } + return commit('', false, { + status: 'failed', + reason: 'download_failed', + }); + } + default: { + if (event.error) { + return finish('failed', 'commit_failed'); + } + if (!event.ok) { + return finish('cancelled', event.reason ?? 'reset'); + } + const r = state.result; + return finish(r.status, r.reason ?? '', r.hash, r.activated); + } + } + }; +} + function harness() { const values = new Map(); values.set('nativeConfig', JSON.stringify({ appKey: 'test-app' })); @@ -50,6 +166,17 @@ function harness() { getBuildTime: () => '123', getBundleHash: async () => 'binary-hash', hasDownloadedVersion: () => state.installed, + isStoredNativeConfig: (config: string) => + harmonyConfigBindings.configsEquivalent( + config, + values.get('nativeConfig') + ), + downloadFullUpdate: async () => { + state.downloads += 1; + if (!state.downloadOK) { + throw new Error('download failed'); + } + }, getBundleUrl: () => { throw new Error('host checks must not resolve the launch bundle again'); }, @@ -60,7 +187,7 @@ function harness() { }; const runtime = evalHarmonyModule( 'SyncCoordinator.ts', - 'runQueryRequest = mockCheck;\nperformAttempts = mockDownload;\n({ check: prepareBundleNative, schedule: scheduleNativeSync });', + 'httpRequest = mockCheck;\n({ check: prepareBundleNative, schedule: scheduleNativeSync });', { BundlePreparationRound, bundlePreparationResult, @@ -68,22 +195,17 @@ function harness() { deviceInfo: { osFullName: 'test-os' }, setTimeout: (callback: () => void) => timers.push(callback), clearTimeout: () => {}, - isSafePathComponent: (hash: string) => /^[a-zA-Z0-9_-]+$/.test(hash), getErrorMessage: (error: unknown) => String(error), - STATUS_NONE, + monotonicNowMs: () => 0, + PushyError: class {}, NativePatchCore: { ...harmonyConfigBindings, - prepareRound: () => JSON.stringify({ body: '{}', query: {} }), - handleResponse: (response: string) => response, + stepRound: scriptedEngine(() => state.decision), }, mockCheck: async () => { state.checks += 1; await state.beforeResponse(); - return state.reachable ? JSON.stringify(state.decision) : undefined; - }, - mockDownload: async () => { - state.downloads += 1; - return state.downloadOK; + return state.reachable ? '{}' : undefined; }, } ) as { From f331f1ed6e3be3440e5db87926c52972f6f1ff87 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 13:32:16 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20leave=20the=20=C2=A712=20status=20t?= =?UTF-8?q?o=20the=20follow-up=20that=20records=20all=20three=20platforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/NATIVE_CHECKUPDATE_DESIGN.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index cf051bdb..20b6c67e 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -645,6 +645,4 @@ full 超时整体失败)、激活判定(`activate || crashHold || forceActiv 进度:1、2 已合入(#673、#674)。iOS 已接入:`RCTPushyOrchestrator.mm` 的 `runOnce` 驱动引擎, 命令由 `perform:` / `download:` / `commit:` 执行,宿主取消判定改用 `HostCancellation`;提交败于配置 -变更时由适配器按配置代数回报 `reason: "config_changed"`。鸿蒙已接入:`SyncCoordinator.ts` 的 `runOnce` 经 `NativePatchCore.stepRound`(NAPI,同步)驱动 -引擎,HTTP 与下载仍是 ArkTS 异步;宿主取消判定改为 C1 等价。`harmonySyncRound.test.ts` 以轨迹向量为脚本 -回放适配器,逐步核对它回给引擎的事件。 +变更时由适配器按配置代数回报 `reason: "config_changed"`。