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 {