From 6cc01a59c50f673e6e126c6a5c5c5a8bbd25030c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Wed, 14 Oct 2026 14:12:03 +0800 Subject: [PATCH 1/2] fix: bound the JS endpoint fallback and close small platform gaps - JS: the check's endpoint fallback issues at most 8 endpoint requests in total, the native round's flowcore::kMaxQueryRequests. The remote list comes from a public mirror and was unbounded, so a long or hostile list made every device fan out to all of its entries on each failed check. check-constant-parity now pins the two values together. - Harmony: buildTime falls back to the app install time when meta.json has no build stamp, as iOS falls back to the Info.plist mtime. An empty buildTime made syncStateWithBinaryVersion skip entirely, so a stale hot update outlived an app upgrade that kept its versionName. - Harmony: the download connect timeout is 15 s, as on Android, instead of the whole remaining phase budget; an unreachable mirror no longer eats the time the next mirror needs. readTimeout keeps the remaining deadline (netstack treats it as a whole-transfer timeout). - README: document nativeConfigSource in both Options tables, and add check-readme-options to `bun lint` so a ClientOptions key without a row fails. Co-Authored-By: Claude Opus 5.5 --- README-CN.md | 1 + README.md | 1 + cpp/update_flow_core/native_round.h | 4 +- harmony/pushy/src/main/ets/DownloadTask.ts | 11 +++-- harmony/pushy/src/main/ets/UpdateContext.ts | 12 +++++ package.json | 2 +- scripts/check-constant-parity.js | 10 ++++ scripts/check-readme-options.js | 55 +++++++++++++++++++++ src/__tests__/endpoint.test.ts | 54 ++++++++++++++++++++ src/endpoint.ts | 23 +++++++-- 10 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 scripts/check-readme-options.js diff --git a/README-CN.md b/README-CN.md index a6d93eff..7b30e15d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -43,6 +43,7 @@ | `disableTelemetry` | `false` | 不向服务端上报生命周期事件(下载/补丁失败、回滚、标记成功) | | `disableErrorReporting` | `false` | 关闭 JS 错误上报(见下节) | | `disableNativeCheck` | `false` | 关闭原生冷启动检测(救砖通道) | +| `nativeConfigSource` | `'javascript'` | 原生检测配置的归属;宿主自己调用 `configure()` 时设为 `'native'`,JS 不再覆盖(见[迁移说明](docs/NATIVE_HOST_API_MIGRATION.md)) | | `testChannel` | `true` | 是否响应 `__rnPushyVersionHash` 深链/二维码;生产构建建议设为 `false` | | `logger` | – | 接收 SDK 全部事件(`type`、`data`)用于自定义日志 | diff --git a/README.md b/README.md index 5f408404..07cb38d9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ through `client.setOptions(...)`. | `disableTelemetry` | `false` | Stop lifecycle events (download/patch failure, rollback, mark success) reaching the server | | `disableErrorReporting` | `false` | Stop JS exception reporting (see below) | | `disableNativeCheck` | `false` | Turn off the native cold-start check that rescues bricked devices | +| `nativeConfigSource` | `'javascript'` | Who owns the persisted native check configuration; `'native'` when the host calls `configure()` itself, so JS never overwrites it ([migration notes](docs/NATIVE_HOST_API_MIGRATION.md)) | | `testChannel` | `true` | Honour `__rnPushyVersionHash` deep links / QR codes; set `false` in production builds | | `logger` | – | Receives every SDK event (`type`, `data`) for your own logging | diff --git a/cpp/update_flow_core/native_round.h b/cpp/update_flow_core/native_round.h index a5827d89..ea5c4b80 100644 --- a/cpp/update_flow_core/native_round.h +++ b/cpp/update_flow_core/native_round.h @@ -31,7 +31,9 @@ namespace flowcore { // Total HTTP requests one query may issue: endpoint queries and remote list -// fetches alike. +// fetches alike. The JS check's endpoint fallback keeps the same budget for +// its endpoint queries (src/endpoint.ts MAX_ENDPOINT_REQUESTS, +// scripts/check-constant-parity.js). constexpr int kMaxQueryRequests = 8; // {"body": "", "query": 0) { + logger.warn( + TAG, + 'meta.json has no build stamp; using the app install time as buildTime', + ); + UpdateContext.cachedBuildTime = String(updateTime); + return UpdateContext.cachedBuildTime; + } return ''; } diff --git a/package.json b/package.json index d8235521..fb1df360 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ ], "scripts": { "prepack": "bun submodule && bun lint", - "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js && node scripts/check-constant-parity.js", + "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js && node scripts/check-constant-parity.js && node scripts/check-readme-options.js", "lint:harmony": "node scripts/check-harmony-types.js", "submodule": "git submodule update --init --recursive", "test": "bun test src/__tests__", diff --git a/scripts/check-constant-parity.js b/scripts/check-constant-parity.js index 6f729bb1..292871e1 100644 --- a/scripts/check-constant-parity.js +++ b/scripts/check-constant-parity.js @@ -16,6 +16,8 @@ // android/.../ErrorCodes.java // src/error.ts (every native code must // be a known UpdateErrorCode) +// cpp/update_flow_core/native_round.h <-> src/endpoint.ts +// (the request budget of one check) 'use strict'; const fs = require('fs'); @@ -186,6 +188,14 @@ const pairs = [ // Every constant in the class must exist in the header too. mirrorPrefix: '', }, + { + // The JS check's endpoint fallback keeps the native round's budget. + header: 'cpp/update_flow_core/native_round.h', + mirror: 'src/endpoint.ts', + parse: parseArkTsConstants, + mirrorName: () => 'MAX_ENDPOINT_REQUESTS', + only: ['MaxQueryRequests'], + }, { header: 'cpp/patch_core/error_codes.h', mirror: 'src/error.ts', diff --git a/scripts/check-readme-options.js b/scripts/check-readme-options.js new file mode 100644 index 00000000..8b4c0c12 --- /dev/null +++ b/scripts/check-readme-options.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Asserts that every ClientOptions key (src/type.ts) has a row in the Options +// reference table of both READMEs: a key added to the type without a row is +// an option users cannot discover. A row's first cell may name several keys +// (`beforeCheckUpdate` / `afterCheckUpdate`). Runs in `bun lint`. +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const read = (relative) => fs.readFileSync(path.join(root, relative), 'utf8'); + +const typeSource = read('src/type.ts'); +const body = typeSource.match(/export interface ClientOptions \{([\s\S]*?)\n\}/); +if (!body) { + console.error('check-readme-options: ClientOptions not found in src/type.ts'); + process.exit(1); +} +const keys = [...body[1].matchAll(/^ {2}([A-Za-z][A-Za-z0-9]*)\??:/gm)].map((m) => m[1]); +// Guards the parser itself: a regex regression must not pass silently. +for (const required of ['appKey', 'updateStrategy', 'nativeConfigSource']) { + if (!keys.includes(required)) { + console.error(`check-readme-options: parser did not find ${required} in ClientOptions`); + process.exit(1); + } +} + +const problems = []; +for (const readme of ['README.md', 'README-CN.md']) { + const documented = new Set(); + for (const line of read(readme).split('\n')) { + const firstCell = line.match(/^\|([^|]*)\|/); + if (!firstCell) { + continue; + } + for (const name of firstCell[1].matchAll(/`([A-Za-z][A-Za-z0-9]*)`/g)) { + documented.add(name[1]); + } + } + for (const key of keys) { + if (!documented.has(key)) { + problems.push(`${readme}: no Options row for \`${key}\` (src/type.ts ClientOptions)`); + } + } +} + +if (problems.length) { + console.error(`check-readme-options: ${problems.length} problem(s)`); + for (const problem of problems) { + console.error(` - ${problem}`); + } + process.exit(1); +} +console.log(`check-readme-options: ${keys.length} options documented in both READMEs`); diff --git a/src/__tests__/endpoint.test.ts b/src/__tests__/endpoint.test.ts index b3faf651..90ad995f 100644 --- a/src/__tests__/endpoint.test.ts +++ b/src/__tests__/endpoint.test.ts @@ -1,6 +1,7 @@ import { describe, expect, mock, test } from 'bun:test'; import { executeEndpointFallback, + MAX_ENDPOINT_REQUESTS, selectFastestSuccessfulEndpoint, } from '../endpoint'; import { dedupeEndpoints } from '../updateFlowCore'; @@ -104,6 +105,59 @@ describe('executeEndpointFallback', () => { }); }); +describe('executeEndpointFallback request budget', () => { + test('asks at most MAX_ENDPOINT_REQUESTS endpoints across all rounds', async () => { + const tryEndpoint = mock(async (endpoint: string) => { + throw new Error(`${endpoint} failed`); + }); + // A long remote list, refreshed with new entries on every round. + let round = 0; + const getRemoteEndpoints = mock(async () => { + round++; + return Array.from({ length: 20 }, (_, i) => `r${round}-${i}`); + }); + + await expect( + executeEndpointFallback({ + configuredEndpoints: ['a', 'b'], + getRemoteEndpoints, + tryEndpoint, + random: () => 0, + hedgeDelayMs: 0, + }) + ).rejects.toThrow('failed'); + + expect(tryEndpoint).toHaveBeenCalledTimes(MAX_ENDPOINT_REQUESTS); + expect(tryEndpoint.mock.calls.map((call) => call[0])).toEqual([ + 'a', + 'b', + 'r1-0', + 'r1-1', + 'r1-2', + 'r1-3', + 'r1-4', + 'r1-5', + ]); + }); + + test('a success within the budget still wins', async () => { + const tryEndpoint = mock(async (endpoint: string) => { + if (endpoint === 'r1-2') { + return 'ok'; + } + throw new Error(`${endpoint} failed`); + }); + const result = await executeEndpointFallback({ + configuredEndpoints: ['a'], + getRemoteEndpoints: async () => ['r1-0', 'r1-1', 'r1-2'], + tryEndpoint, + random: () => 0, + hedgeDelayMs: 0, + }); + expect(result.endpoint).toBe('r1-2'); + }); +}); + describe('selectFastestSuccessfulEndpoint', () => { test('returns a hedged fast success without waiting for a slow earlier candidate', async () => { const aborted: string[] = []; diff --git a/src/endpoint.ts b/src/endpoint.ts index 5ed24bc1..0d7e6617 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -20,6 +20,7 @@ export interface ExecuteEndpointFallbackOptions { random?: () => number; now?: () => number; hedgeDelayMs?: number; + maxRequests?: number; onFirstFailure?: (failure: EndpointAttemptFailure) => void | Promise; } @@ -32,6 +33,15 @@ const normalizeError = (error: unknown) => { export const DEFAULT_HEDGE_DELAY_MS = 250; +/** + * Endpoint requests one check may issue in total, as the native round's + * flowcore::kMaxQueryRequests (cpp/update_flow_core/native_round.h). The + * remote list comes from a public mirror: without a bound, a long (or + * hostile) list would make every device fan out to all of its entries on + * each failed check. + */ +export const MAX_ENDPOINT_REQUESTS = 8; + /** * Hedged race over the candidate endpoints: the first candidate starts * immediately, each following one is only started after `hedgeDelayMs` of @@ -137,6 +147,7 @@ export async function executeEndpointFallback({ random = Math.random, now = Date.now, hedgeDelayMs = DEFAULT_HEDGE_DELAY_MS, + maxRequests = MAX_ENDPOINT_REQUESTS, onFirstFailure, }: ExecuteEndpointFallbackOptions): Promise> { const excludedEndpoints = new Set(); @@ -164,14 +175,15 @@ export async function executeEndpointFallback({ excludedEndpoints.add(firstEndpoint); await onFirstFailure?.(firstFailure); let lastError = firstFailure.error; - - while (true) { + // Every endpoint is asked at most once, so the excluded set counts the + // requests issued so far. + while (excludedEndpoints.size < maxRequests) { const remoteEndpoints = getRemoteEndpoints ? await getRemoteEndpoints().catch(() => []) : []; - candidates = dedupeEndpoints([...candidates, ...remoteEndpoints]).filter( - (endpoint) => !excludedEndpoints.has(endpoint) - ); + candidates = dedupeEndpoints([...candidates, ...remoteEndpoints]) + .filter((endpoint) => !excludedEndpoints.has(endpoint)) + .slice(0, maxRequests - excludedEndpoints.size); if (!candidates.length) { throw lastError; @@ -193,5 +205,6 @@ export async function executeEndpointFallback({ lastError = failure.error; } } + throw lastError; } } From 9ab79a1a15061f4cf8332fc461793bd820b96c72 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Wed, 14 Oct 2026 14:18:26 +0800 Subject: [PATCH 2/2] fix(endpoint): a zero request budget issues no request Co-Authored-By: Claude Opus 5.5 --- src/__tests__/endpoint.test.ts | 12 ++++++++++++ src/endpoint.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/__tests__/endpoint.test.ts b/src/__tests__/endpoint.test.ts index 90ad995f..2b4cb066 100644 --- a/src/__tests__/endpoint.test.ts +++ b/src/__tests__/endpoint.test.ts @@ -140,6 +140,18 @@ describe('executeEndpointFallback request budget', () => { ]); }); + test('a zero budget issues no request', async () => { + const tryEndpoint = mock(async () => 'ok'); + await expect( + executeEndpointFallback({ + configuredEndpoints: ['a'], + tryEndpoint, + maxRequests: 0, + }) + ).rejects.toMatchObject({ code: 'NO_ENDPOINTS' }); + expect(tryEndpoint).not.toHaveBeenCalled(); + }); + test('a success within the budget still wins', async () => { const tryEndpoint = mock(async (endpoint: string) => { if (endpoint === 'r1-2') { diff --git a/src/endpoint.ts b/src/endpoint.ts index 0d7e6617..9aa46ad6 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -155,7 +155,8 @@ export async function executeEndpointFallback({ // is pure policy; this side only executes it. let candidates = orderEndpointCandidates(configuredEndpoints, random()); - if (!candidates.length) { + // No budget means no request at all, like no endpoint. + if (!candidates.length || maxRequests < 1) { throw new UpdateError(i18n.t('error_no_endpoints'), 'NO_ENDPOINTS'); }