Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)用于自定义日志 |

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
4 changes: 3 additions & 1 deletion cpp/update_flow_core/native_round.h
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<checkUpdate request text>", "query": <first NextQueryStep
Expand Down
11 changes: 7 additions & 4 deletions harmony/pushy/src/main/ets/DownloadTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
} from './ErrorCodes';
import { BUNDLE_DELTA_ENTRY } from './Texts';

// 连接超时,与 Android DownloadTask 的 connectTimeout(15s)一致。
const CONNECT_TIMEOUT_MS = 15000;
const TAG = 'DownloadTask';

// staging 目录里的条目名 + __diff.json 原文:由原生内核统一校验与解析
Expand Down Expand Up @@ -752,14 +754,15 @@ export class DownloadTask {
}, remainingMs);
});
// netstack 把 readTimeout 交给 libcurl 的 CURLOPT_TIMEOUT_MS(整次传输
// 总时长)而非空闲超时:固定 60s 会让慢网上的大全量包次次失败。两个
// 超时都取剩余 deadline;卡死由下面的 60s 不活动看门狗负责——两种
// 语义下都正确。
// 总时长)而非空闲超时:固定 60s 会让慢网上的大全量包次次失败。所以
// readTimeout 取剩余 deadline,卡死由下面的 60s 不活动看门狗负责——
// 两种语义下都正确。connectTimeout 没有这个问题,与 Android 一样
// 15s:连不上的镜像不该吃掉整个阶段预算,下一个镜像还要用。
const responseCode = await Promise.race([
httpRequest.requestInStream(params.url, {
method: http.RequestMethod.GET,
readTimeout: remainingMs,
connectTimeout: remainingMs,
connectTimeout: Math.min(CONNECT_TIMEOUT_MS, remainingMs),
header: requestHeader,
}),
deadlinePromise,
Expand Down
12 changes: 12 additions & 0 deletions harmony/pushy/src/main/ets/UpdateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ export class UpdateContext {
`Failed to read build time from raw file: ${getErrorMessage(error)}`,
);
}
// 绝不返回空串(与 iOS 用 Info.plist mtime 兜底一致):buildTime 为空时
// syncStateWithBinaryVersion 整体跳过,保留 versionName 的重装二进制会
// 让旧热更活过应用升级。每次(覆盖)安装 updateTime 都会变,用它顶替。
const updateTime = this.getBundleModifiedTime();
if (updateTime > 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 '';
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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__",
Expand Down
10 changes: 10 additions & 0 deletions scripts/check-constant-parity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down
55 changes: 55 additions & 0 deletions scripts/check-readme-options.js
Original file line number Diff line number Diff line change
@@ -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`);
66 changes: 66 additions & 0 deletions src/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, mock, test } from 'bun:test';
import {
executeEndpointFallback,
MAX_ENDPOINT_REQUESTS,
selectFastestSuccessfulEndpoint,
} from '../endpoint';
import { dedupeEndpoints } from '../updateFlowCore';
Expand Down Expand Up @@ -104,6 +105,71 @@ 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 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') {
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[] = [];
Expand Down
26 changes: 20 additions & 6 deletions src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ExecuteEndpointFallbackOptions<T> {
random?: () => number;
now?: () => number;
hedgeDelayMs?: number;
maxRequests?: number;
onFirstFailure?: (failure: EndpointAttemptFailure) => void | Promise<void>;
}

Expand All @@ -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
Expand Down Expand Up @@ -137,14 +147,16 @@ export async function executeEndpointFallback<T>({
random = Math.random,
now = Date.now,
hedgeDelayMs = DEFAULT_HEDGE_DELAY_MS,
maxRequests = MAX_ENDPOINT_REQUESTS,
Comment thread
sunnylqm marked this conversation as resolved.
onFirstFailure,
}: ExecuteEndpointFallbackOptions<T>): Promise<EndpointAttemptSuccess<T>> {
const excludedEndpoints = new Set<string>();
// The candidate ordering (random first pick, configured order as fallback)
// 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');
}

Expand All @@ -164,14 +176,15 @@ export async function executeEndpointFallback<T>({
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;
Expand All @@ -193,5 +206,6 @@ export async function executeEndpointFallback<T>({
lastError = failure.error;
}
}
throw lastError;
}
}
Loading