From 2813456a3291e861d0eac7186772fe530fe52062 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 00:18:17 +0800 Subject: [PATCH] feat(native): bounded prepareBundle on Android and HarmonyOS Only iOS let a host bound its wait (prepareBundle(timeout:completion:)). Android waited on the round's latch without limit, and ran the round on the caller's single host worker, so a second caller queued behind the first caller's whole wait. HarmonyOS awaited the round without limit. - Android: PushyRuntime.prepareBundle(context, timeoutMillis, callback). The round runs on its own thread (claimRound / runClaimedRound, as iOS), the caller waits at most the timeout and gets failed/timeout on expiry while the round carries on. Without a timeout it waits at most 30 minutes, the iOS default. Host waits run on a cached daemon pool, so one caller's wait never delays another's. - HarmonyOS: prepareBundle(timeoutMs?) races the round against a timer; without one it keeps waiting for the round (nothing blocks on ArkTS). - READMEs document the three timeout entry points. Co-Authored-By: Claude Opus 5.5 --- README-CN.md | 5 +++ README.md | 7 +++ .../modules/update/PushyRuntime.java | 27 ++++++++++-- .../modules/update/SyncCoordinator.java | 44 ++++++++++++++----- .../reactnative/modules/update/Threads.java | 9 ++++ .../main/ets/PushyFileJSBundleProvider.ets | 11 +++-- harmony/pushy/src/main/ets/SyncCoordinator.ts | 22 +++++++++- src/__tests__/nativeHostApi.test.ts | 31 ++++++++++++- src/__tests__/nativeHostApiNaming.test.ts | 4 +- 9 files changed, 137 insertions(+), 23 deletions(-) diff --git a/README-CN.md b/README-CN.md index 7b30e15d..0e25bab5 100644 --- a/README-CN.md +++ b/README-CN.md @@ -180,3 +180,8 @@ Hermes 字节码对通用二进制 diff 极不友好,我们在两个环节同 Apple 平台使用 `RCTPushy`,Harmony 使用已有的 `PushyFileJSBundleProvider`。 请在正常启动 bundle 解析后调用。改名不改变 JS 桥接契约,但原生接入代码需要迁移并重新构建。 详见[原生接口迁移说明](docs/NATIVE_HOST_API_MIGRATION.md)。 + +三端都可以传入等待上限:`PushyRuntime.prepareBundle(context, timeoutMillis, callback)`、 +`RCTPushy.prepareBundle(timeout:completion:)`、`prepareBundle(timeoutMs)`。超时返回 +`failed` / `timeout`,检测轮次继续在后台运行,结果仍对下次启动生效。不传时 +Android 与 iOS 最多等 30 分钟,HarmonyOS 等到轮次结束。 diff --git a/README.md b/README.md index 07cb38d9..719d5965 100644 --- a/README.md +++ b/README.md @@ -163,3 +163,10 @@ Bridge-free hosts use `PushyRuntime.prepareBundle` (Android), (HarmonyOS), after normal launch bundle resolution. See the [native host API migration guide](docs/NATIVE_HOST_API_MIGRATION.md) for the source-breaking rename, unchanged JS bridge contract, and native rebuild steps. + +Each platform also takes a timeout: `PushyRuntime.prepareBundle(context, +timeoutMillis, callback)`, `RCTPushy.prepareBundle(timeout:completion:)` and +`prepareBundle(timeoutMs)`. On expiry the result is `failed` / `timeout`; the +round keeps running in the background and still applies to the next launch. +Without one, Android and iOS wait at most 30 minutes and HarmonyOS waits for +the round. diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java index 7c791380..6eac9f6b 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java +++ b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java @@ -15,9 +15,12 @@ public interface Callback { void onComplete(BundlePreparationResult result); } - // A single waiting worker, not one thread per caller. The actual round is - // shared with cold start and crash rescue by SyncCoordinator. - private static final Executor WORKER = Threads.single("pushy-host-sync", true); + // Waiting threads, reused while idle. Not a single worker: each caller's + // wait is bounded by its own timeout, which a queue behind another + // caller's longer wait would break (iOS waits on a concurrent queue too). + // The actual round is shared with cold start and crash rescue by + // SyncCoordinator, on its own thread. + private static final Executor WORKER = Threads.cached("pushy-host-sync"); public interface ConfigurationCallback { /** Main thread; null means configuration was persisted successfully. */ @@ -70,8 +73,24 @@ private PushyRuntime() { * UpdateContext.getBundleUrl. Do not resolve the bundle again just to call * this method: bundle resolution consumes first-load/rollback markers. * Missing persisted configuration and debug builds are reported as skipped. + * + * Waits at most 30 minutes for the round (longer than any round takes). */ public static void prepareBundle(Context context, final Callback callback) { + prepareBundle(context, SyncCoordinator.DEFAULT_PREPARE_TIMEOUT_MS, callback); + } + + /** + * Same as prepareBundle(Context, Callback), but the callback runs after at + * most timeoutMillis. On expiry the result is status failed, reason + * timeout; the round keeps running in the background and its result still + * applies to the next launch. + */ + public static void prepareBundle( + Context context, + final long timeoutMillis, + final Callback callback + ) { if (context == null || callback == null) { throw new IllegalArgumentException("context and callback are required"); } @@ -83,7 +102,7 @@ public static void prepareBundle(Context context, final Callback callback) { outcome = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); } else { outcome = SyncCoordinator.prepareBundle( - UpdateContext.getInstance(applicationContext)); + UpdateContext.getInstance(applicationContext), timeoutMillis); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index 4f0f5fd5..b7f7cf19 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -82,8 +82,19 @@ final class SyncCoordinator { private static volatile long roundConfigGeneration = -1; private static volatile String roundConfigJson; - /** Blocking only on the host API's worker; never call on the UI thread. */ - static BundlePreparationResult prepareBundle(UpdateContext context) throws InterruptedException { + // prepareBundle without an explicit timeout waits this long at most: past + // the longest round (8 query requests, then two 600s download phases). + // Same default as iOS (kPushyHostPrepareDefaultTimeout). + static final long DEFAULT_PREPARE_TIMEOUT_MS = 30 * 60 * 1000L; + + /** + * Blocking only on the host API's worker; never call on the UI thread. + * Waits at most timeoutMs for this process's round: on expiry the result + * is failed/timeout, and the round keeps running in the background (its + * result still applies to the next launch), as on iOS. + */ + static BundlePreparationResult prepareBundle(UpdateContext context, long timeoutMs) + throws InterruptedException { if (BuildConfig.DEBUG) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); } @@ -95,11 +106,17 @@ static BundlePreparationResult prepareBundle(UpdateContext context) throws Inter if (rejection != null) { return rejection; } - startRound(0); + // Run the round on its own thread rather than this caller's, so the + // wait below can be bounded. + if (claimRound()) { + Threads.startDaemon("pushy-native-sync", () -> runClaimedRound(0)); + } if (!roundStarted.get()) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "config_changed"); } - roundDone.await(); + if (!roundDone.await(Math.max(0, timeoutMs), TimeUnit.MILLISECONDS)) { + return BundlePreparationResult.of(BundlePreparationResult.FAILED, "timeout"); + } if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration() || !FlowBridge.configsEquivalent(configJson, roundConfigJson) || !FlowBridge.configsEquivalent(configJson, context.getKv(KEY_CONFIG))) { @@ -216,14 +233,19 @@ static void onConfigured(UpdateContext context) { * and download phase to the remaining budget. */ private static void startRound(long deadlineNanos) { - // An automatic check before first-run provisioning must not consume - // the process's only round. Hosts may configure later in this launch. - if (!hasRunnableConfig(sContext)) { - return; - } - if (!roundStarted.compareAndSet(false, true)) { - return; + if (claimRound()) { + runClaimedRound(deadlineNanos); } + } + + // Claims the process's only round. An automatic check before first-run + // provisioning must not consume it: hosts may configure later in this + // launch. + private static boolean claimRound() { + return hasRunnableConfig(sContext) && roundStarted.compareAndSet(false, true); + } + + private static void runClaimedRound(long deadlineNanos) { try { runOnce(sContext, sLaunchRolledBackVersion, deadlineNanos); } catch (Throwable e) { diff --git a/android/src/main/java/cn/reactnative/modules/update/Threads.java b/android/src/main/java/cn/reactnative/modules/update/Threads.java index dfe615b9..9472f4b0 100644 --- a/android/src/main/java/cn/reactnative/modules/update/Threads.java +++ b/android/src/main/java/cn/reactnative/modules/update/Threads.java @@ -17,6 +17,15 @@ static ExecutorService single(final String name, final boolean daemon) { }); } + /** Daemon threads on demand, reused while idle: concurrent tasks never queue. */ + static ExecutorService cached(final String name) { + return Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + return thread; + }); + } + /** Starts a one-off daemon thread: it never keeps a dying process alive. */ static Thread startDaemon(String name, Runnable task) { return startDaemon(name, -1, task); diff --git a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets index daf03618..4746d66a 100644 --- a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets +++ b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets @@ -53,9 +53,14 @@ export class PushyFileJSBundleProvider extends JSBundleProvider { await this.updateContext.setNativeConfig(config); } - /** Call after the host's real bundle resolution; never resolves it again. */ - prepareBundle(): Promise { - return prepareBundleNative(this.updateContext); + /** + * Call after the host's real bundle resolution; never resolves it again. + * With timeoutMs, resolves after at most that long: on expiry the result is + * failed/timeout and the round keeps running in the background (its result + * still applies to the next launch). + */ + prepareBundle(timeoutMs?: number): Promise { + return prepareBundleNative(this.updateContext, timeoutMs); } getAppKeys(): string[] { diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index 2034bf65..45254753 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -141,8 +141,13 @@ function configurationError(context: UpdateContext): BundlePreparationResult | u return readNativeConfig(context).rejection; } +// timeoutMs bounds how long the host waits, as iOS/Android's +// prepareBundle(timeout): on expiry the result is failed/timeout and the round +// keeps running (its result still applies to the next launch). Without one the +// host waits for the round; nothing blocks meanwhile (single-threaded ArkTS). export async function prepareBundleNative( context: UpdateContext, + timeoutMs?: number, ): Promise { if (scheduledContext !== context) { return bundlePreparationResult('skipped', 'not_initialized'); @@ -152,7 +157,22 @@ export async function prepareBundleNative( return read.rejection; } const configJson = read.json; - const result = await startNativeRound(context, scheduledRollback); + const round = startNativeRound(context, scheduledRollback); + let result: BundlePreparationResult; + if (timeoutMs === undefined) { + result = await round; + } else { + let timer: number | undefined; + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), Math.max(0, timeoutMs)); + }); + const settled = await Promise.race([round, expired]); + clearTimeout(timer); + if (settled === undefined) { + return bundlePreparationResult('failed', 'timeout'); + } + result = settled; + } if (roundConfigGeneration !== context.getNativeConfigGeneration() || configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { return bundlePreparationResult('cancelled', 'config_changed'); diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index 3fdfa6a5..2bfca47e 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -67,6 +67,7 @@ function harness() { logger: { info() {}, warn() {}, error() {} }, 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, @@ -86,14 +87,17 @@ function harness() { }, } ) as { - check: (ctx: typeof context) => Promise; + check: ( + ctx: typeof context, + timeoutMs?: number + ) => Promise; schedule: (ctx: typeof context, rollback: string) => void; }; return { values, state, timers, - check: () => runtime.check(context), + check: (timeoutMs?: number) => runtime.check(context, timeoutMs), initialize: () => runtime.schedule(context, 'rolled-back-version'), }; } @@ -200,6 +204,29 @@ describe('native host API orchestration', () => { ); }); + test('a host timeout returns failed/timeout while the round carries on', async () => { + const h = harness(); + h.initialize(); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + h.state.beforeResponse = () => held; + const armedBefore = h.timers.length; + const waiting = h.check(5000); + // The host's timer is the first one this call arms (the round arms its + // own after it); fire it while the response is still held. + await Promise.resolve(); + h.timers[armedBefore](); + expect(await waiting).toEqual(bundlePreparationResult('failed', 'timeout')); + + // The round was not cut short: once the response arrives it completes, + // and a later call gets its result. + release(); + expect((await h.check()).status).toBe('noUpdate'); + expect(h.state.checks).toBe(1); + }); + test('reset and configuration changes invalidate completed snapshots', async () => { const reset = harness(); reset.initialize(); diff --git a/src/__tests__/nativeHostApiNaming.test.ts b/src/__tests__/nativeHostApiNaming.test.ts index 5c47241d..aec41a49 100644 --- a/src/__tests__/nativeHostApiNaming.test.ts +++ b/src/__tests__/nativeHostApiNaming.test.ts @@ -59,10 +59,10 @@ describe('native host API naming', () => { const provider = source(`${harmony}PushyFileJSBundleProvider.ets`); const exports = source('harmony/pushy/index.ets'); expect(provider).toContain( - 'prepareBundle(): Promise' + 'prepareBundle(timeoutMs?: number): Promise' ); expect(provider).toContain( - 'return prepareBundleNative(this.updateContext)' + 'return prepareBundleNative(this.updateContext, timeoutMs)' ); expect(provider).toContain('configure(options: PushyConfiguration)'); expect(exports).toContain('export type { BundlePreparationResult }');