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
5 changes: 5 additions & 0 deletions README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 等到轮次结束。
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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");
}
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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))) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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<BundlePreparationResult> {
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<BundlePreparationResult> {
return prepareBundleNative(this.updateContext, timeoutMs);
}

getAppKeys(): string[] {
Expand Down
22 changes: 21 additions & 1 deletion harmony/pushy/src/main/ets/SyncCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BundlePreparationResult> {
if (scheduledContext !== context) {
return bundlePreparationResult('skipped', 'not_initialized');
Expand All @@ -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<undefined>((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');
Expand Down
31 changes: 29 additions & 2 deletions src/__tests__/nativeHostApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -86,14 +87,17 @@ function harness() {
},
}
) as {
check: (ctx: typeof context) => Promise<BundlePreparationResult>;
check: (
ctx: typeof context,
timeoutMs?: number
) => Promise<BundlePreparationResult>;
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'),
};
}
Expand Down Expand Up @@ -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<void>((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();
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/nativeHostApiNaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BundlePreparationResult>'
'prepareBundle(timeoutMs?: number): Promise<BundlePreparationResult>'
);
expect(provider).toContain(
'return prepareBundleNative(this.updateContext)'
'return prepareBundleNative(this.updateContext, timeoutMs)'
);
expect(provider).toContain('configure(options: PushyConfiguration)');
expect(exports).toContain('export type { BundlePreparationResult }');
Expand Down
Loading