diff --git a/android/lib/arm64-v8a/librnpushy.so b/android/lib/arm64-v8a/librnpushy.so index ef78569b..e64ed1cc 100755 Binary files a/android/lib/arm64-v8a/librnpushy.so and b/android/lib/arm64-v8a/librnpushy.so differ diff --git a/android/lib/armeabi-v7a/librnpushy.so b/android/lib/armeabi-v7a/librnpushy.so index 68c4cac5..8ed31968 100755 Binary files a/android/lib/armeabi-v7a/librnpushy.so and b/android/lib/armeabi-v7a/librnpushy.so differ diff --git a/android/lib/x86/librnpushy.so b/android/lib/x86/librnpushy.so index 0bc21312..18eae6ab 100755 Binary files a/android/lib/x86/librnpushy.so and b/android/lib/x86/librnpushy.so differ diff --git a/android/lib/x86_64/librnpushy.so b/android/lib/x86_64/librnpushy.so index 1767d630..fcd4fc91 100755 Binary files a/android/lib/x86_64/librnpushy.so and b/android/lib/x86_64/librnpushy.so differ diff --git a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java index 5871487e..b779b09b 100644 --- a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java +++ b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java @@ -23,6 +23,13 @@ private FlowBridge() { */ static native String nextQueryStep(String stateJson, String responseText); + /** + * One step of the round driver (cpp/update_flow_core/round_engine.h): + * {"state", "commands"} for the previous state (null to start) and the + * event. Null only when the core threw. + */ + static native String stepRound(String stateJson, String eventJson); + /** The decision with planned attempts, hashInfo and the response-cache text. */ static native String handleResponse( String responseText, String configJson, String envJson, String requestBody, 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 b7f7cf19..30055911 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -43,7 +43,6 @@ final class SyncCoordinator { // next launch means the previous process died mid-round (a crash rescue // was truncated): that launch resumes immediately instead of waiting 5s. static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete"; - private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600; // The check response (and a remote queryUrls list) is a small JSON // document; anything bigger is a broken or hijacked endpoint. private static final long MAX_QUERY_RESPONSE_BYTES = 1024 * 1024; @@ -319,53 +318,139 @@ private static void activatePendingVersion(UpdateContext context) { } } + // The engine's clock: milliseconds on System.nanoTime, offset from this + // class's load so every value is positive (0 means "no deadline"). The + // nanoTime origin is arbitrary, so a hold deadline in nanos may be + // negative: only 0 means none on this side too. + private static final long CLOCK_ORIGIN_NANOS = System.nanoTime() - TimeUnit.SECONDS.toNanos(1); + + static double engineMillis(long nanos) { + return (nanos - CLOCK_ORIGIN_NANOS) / 1e6; + } + + private static long engineNanos(double millis) { + return CLOCK_ORIGIN_NANOS + (long) (millis * 1e6); + } + + /** + * The round itself is the shared engine's (cpp/update_flow_core + * round_engine.h, NATIVE_CHECKUPDATE_DESIGN §12): this drives it on the + * calling thread and runs what it asks for — the breadcrumb, the launch + * facts, the requests, the downloads and the commit. + */ private static void runOnce( UpdateContext context, String launchRolledBackVersion, long deadlineNanos ) throws JSONException { final long resetGeneration = UpdateContext.getResetGeneration(); + final long configGeneration = UpdateContext.getNativeConfigGeneration(); roundGeneration = resetGeneration; - roundConfigGeneration = UpdateContext.getNativeConfigGeneration(); + roundConfigGeneration = configGeneration; roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "check_failed"); String configJson = context.getKv(KEY_CONFIG); roundConfigJson = configJson; - BundlePreparationResult rejection = configRejection(configJson); - if (rejection != null) { - roundResult = rejection; - return; - } - // Keep the existing interrupted-round breadcrumb and reset generation. - try { - context.setKv(KEY_ROUND_INCOMPLETE, "1"); - } catch (IllegalStateException ignored) { - } + + JSONObject event = new JSONObject() + .put("type", "start") + .put("config", configJson == null ? "" : configJson) + .put("resetGeneration", resetGeneration) + .put("configGeneration", configGeneration) + .put("sample", Math.random()) + .put("deadline", deadlineNanos != 0 ? engineMillis(deadlineNanos) : 0); + String state = null; + boolean marked = false; try { - runConfiguredRound( - context, launchRolledBackVersion, deadlineNanos, resetGeneration, configJson); + while (true) { + event.put("now", engineMillis(System.nanoTime())) + .put("crashHold", crashHoldActive); + String step = FlowBridge.stepRound(state, event.toString()); + if (step == null) { + roundResult = BundlePreparationResult.of( + BundlePreparationResult.FAILED, "internal_error"); + return; + } + JSONObject result = new JSONObject(step); + state = result.getJSONObject("state").toString(); + JSONArray commands = result.getJSONArray("commands"); + event = new JSONObject(); + for (int i = 0; i < commands.length(); i++) { + JSONObject command = commands.getJSONObject(i); + String type = command.getString("type"); + if ("markIncomplete".equals(type)) { + marked = true; + try { + context.setKv(KEY_ROUND_INCOMPLETE, "1"); + } catch (IllegalStateException ignored) { + } + } else if ("finish".equals(type)) { + finishRound(command, resetGeneration); + return; + } else { + event = perform(context, launchRolledBackVersion, command, configGeneration); + } + } + } } finally { // A committed round already cleared the breadcrumb in its own // commit (UpdateContext.commitSyncResult); only rounds that ended // without committing need the extra write. - try { - if (context.getKv(KEY_ROUND_INCOMPLETE) != null) { - context.removeKv(KEY_ROUND_INCOMPLETE); + if (marked) { + try { + if (context.getKv(KEY_ROUND_INCOMPLETE) != null) { + context.removeKv(KEY_ROUND_INCOMPLETE); + } + } catch (IllegalStateException ignored) { } - } catch (IllegalStateException ignored) { } } } - private static void runConfiguredRound( + /** Runs one engine command and returns the event that answers it. */ + private static JSONObject perform( UpdateContext context, String launchRolledBackVersion, - long deadlineNanos, - long resetGeneration, - String configJson + JSONObject command, + long configGeneration ) throws JSONException { + String type = command.getString("type"); + switch (type) { + case "collectEnv": + return new JSONObject() + .put("type", "env") + .put("env", launchEnv(context, launchRolledBackVersion)); + case "http": { + boolean post = command.optBoolean("post"); + String text = httpRequest( + command.getString("url"), + post ? command.getString("body") : null, + (long) command.getDouble("timeoutMs"), + (long) command.getDouble("callTimeoutMs")); + JSONObject event = new JSONObject().put("type", "http"); + if (text != null) { + event.put("text", text).put("at", System.currentTimeMillis() / 1000.0); + } + return event; + } + case "checkInstalled": + return new JSONObject() + .put("type", "installed") + .put("installed", context.hasCompletedVersion(command.getString("hash"))); + case "download": + return download(command, downloadStarter(context)); + case "commit": + return commit(context, command, configGeneration); + default: + // Unknown to this adapter: the engine finishes the round. + return new JSONObject().put("type", type); + } + } + + // This launch as the shared core sees it (native_round.h). Absent values + // (null) are left out. + private static JSONObject launchEnv(UpdateContext context, String launchRolledBackVersion) + throws JSONException { String currentVersion = context.getCurrentVersion(); - // This launch as the shared core sees it (native_round.h). Absent - // values (null) are left out. JSONObject env = new JSONObject(); env.put("packageVersion", context.getPackageVersion()); // The JS constant's form, so JS can reuse the response cache. @@ -381,111 +466,57 @@ private static void runConfiguredRound( // same value so this request can be fingerprinted against the JS one. env.put("os", "android " + Build.VERSION.SDK_INT); env.put("bundleHash", context.computeBundleHash()); - String envJson = env.toString(); - - String preparedJson = FlowBridge.prepareRound(configJson, envJson, Math.random()); - if (preparedJson == null) { - roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_request"); - return; - } - JSONObject prepared = new JSONObject(preparedJson); - String body = prepared.getString("body"); - String responseText = - runQueryRequest(prepared.getJSONObject("query").toString(), body, deadlineNanos); - if (responseText == null) { - Log.i(Texts.LOG_TAG, - "native sync: no endpoint reachable, giving up until next launch"); - return; - } - // Cache freshness is anchored to when the server response arrived, - // not to when a potentially long download/patch/activation finished. - String decisionJson = FlowBridge.handleResponse( - responseText, configJson, envJson, body, System.currentTimeMillis() / 1000); - if (decisionJson == null) { - roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_response"); - return; - } - JSONObject decision = new JSONObject(decisionJson); - // Built once; every commit below caches it for JS. - final String responseCacheJson = decision.getString("cache"); - if (!"download".equals(decision.optString("action"))) { - boolean committed = context.commitSyncResult( - resetGeneration, null, null, false, responseCacheJson); - roundResult = committed - ? BundlePreparationResult.of(BundlePreparationResult.NO_UPDATE, decision.optString("reason")) - : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset"); - Log.i(Texts.LOG_TAG, - "native sync: nothing to do (" + decision.optString("reason") + ")"); - return; - } - String hash = decision.optString("hash", ""); - if (!UpdateFileUtils.isSafePathComponent(hash)) { - roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_response"); - return; - } - - boolean downloaded = context.hasCompletedVersion(hash); - if (!downloaded) { - downloaded = performAttempts( - decision.optJSONArray("attempts"), deadlineNanos, - downloadStarter(context, hash, currentVersion)); - } - if (!downloaded) { - // The native attempt has finished, so JS may safely reuse the - // response and retry through its own strategy chain. - boolean committed = context.commitSyncResult( - resetGeneration, null, null, false, responseCacheJson); - roundResult = BundlePreparationResult.of( - committed ? BundlePreparationResult.FAILED : BundlePreparationResult.CANCELLED, - committed ? "download_failed" : "reset"); - return; - } + return env; + } - // Version info (mirroring the JS side's setLocalHashInfo, the - // forceBoot rescue marked by the core), the activation and the - // response cache all land in one atomic commit — see - // UpdateContext.commitSyncResult. - JSONObject hashInfo = decision.getJSONObject("hashInfo"); - if (crashHoldActive) { - hashInfo.put(INFO_CRASH_HOLD, true); - } - String hashInfoJson = hashInfo.toString(); - // Silent strategies or a server-marked forceBoot version (per-version - // remote override — the brick rescue) activate for the next launch; - // otherwise activation stays with the JS side. Unless a crash is - // being held: JS is dead, deferring to it would leave the fix on - // disk forever (§11.3). - boolean activate = decision.optBoolean("activate", false) || crashHoldActive; - boolean committed; + // Version info, the activation and the response cache land in one atomic + // commit (UpdateContext.commitSyncResult). A config change bumps the + // reset generation too, so the commit refuses either; the config + // generation tells the two apart. + private static JSONObject commit(UpdateContext context, JSONObject command, long configGeneration) + throws JSONException { + String hash = command.getString("hash"); + String hashInfo = command.getString("hashInfo"); + JSONObject event = new JSONObject().put("type", "committed"); try { - committed = context.commitSyncResult( - resetGeneration, - hash, - hashInfoJson, - activate, - responseCacheJson); + boolean committed = context.commitSyncResult( + (long) command.getDouble("resetGeneration"), + hash.isEmpty() ? null : hash, + hashInfo.isEmpty() ? null : hashInfo, + command.getBoolean("activate"), + command.getString("cache")); + event.put("ok", committed); + if (!committed && UpdateContext.getNativeConfigGeneration() != configGeneration) { + event.put("reason", "config_changed"); + } } catch (Exception e) { Log.w(Texts.LOG_TAG, "native sync: commit failed: " + e); - roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "commit_failed"); + event.put("error", true); + } + return event; + } + + private static void finishRound(JSONObject finish, long resetGeneration) { + String status = finish.optString("status"); + String reason = finish.optString("reason"); + if (!BundlePreparationResult.DOWNLOADED.equals(status)) { + Log.i(Texts.LOG_TAG, "native sync: " + status + " (" + reason + ")"); + roundResult = BundlePreparationResult.of(status, reason); return; } - if (!committed) { - Log.i(Texts.LOG_TAG, "native sync: reset during round, dropping result"); - } else if (activate) { + String hash = finish.optString("hash"); + boolean activated = finish.optBoolean("activated"); + if (activated) { unactivatedHash = null; - Log.i(Texts.LOG_TAG, - "native sync: downloaded " + hash + " and set for next launch"); + Log.i(Texts.LOG_TAG, "native sync: downloaded " + hash + " and set for next launch"); } else { // Remembered so a crash later in this process can still activate // it (activatePendingVersion) — JS never will. unactivatedGeneration = resetGeneration; unactivatedHash = hash; - Log.i(Texts.LOG_TAG, - "native sync: downloaded " + hash + ", activation left to JS"); + Log.i(Texts.LOG_TAG, "native sync: downloaded " + hash + ", activation left to JS"); } - roundResult = committed - ? BundlePreparationResult.downloaded(hash, activate) - : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset"); + roundResult = BundlePreparationResult.downloaded(hash, activated); } private static final OkHttpClient httpClient = HttpClients.base().newBuilder() @@ -494,7 +525,10 @@ private static void runConfiguredRound( .callTimeout(15, TimeUnit.SECONDS) .build(); - private static String httpRequest(String url, String postBody, long deadlineNanos) { + // The engine's per-request bounds, already capped by the round deadline. + private static String httpRequest( + String url, String postBody, long timeoutMs, long callTimeoutMs + ) { try { Request.Builder builder = new Request.Builder().url(url).header("Accept", "application/json"); @@ -502,9 +536,11 @@ private static String httpRequest(String url, String postBody, long deadlineNano builder.post(RequestBody.create( postBody, MediaType.parse("application/json; charset=utf-8"))); } - // Crash-rescue budget: never let a single request outlive the - // handler's hold window. - OkHttpClient client = HttpClients.withDeadline(httpClient, deadlineNanos); + OkHttpClient client = httpClient.newBuilder() + .connectTimeout(Math.max(1, timeoutMs), TimeUnit.MILLISECONDS) + .readTimeout(Math.max(1, timeoutMs), TimeUnit.MILLISECONDS) + .callTimeout(Math.max(1, callTimeoutMs), TimeUnit.MILLISECONDS) + .build(); try (Response response = client.newCall(builder.build()).execute()) { // Same rule as the artifact download: an https endpoint that // redirects to plaintext http is a failed endpoint. @@ -546,56 +582,20 @@ private static String readBoundedBody(ResponseBody body) throws IOException { return source.readString(charset == null ? StandardCharsets.UTF_8 : charset); } - /** - * Sequential fallback over the endpoints (§5.1), one request at a time - * with its own timeout, as the shared core walks it - * (FlowBridge.nextQueryStep: order, list discovery, https-only remote - * bases, budget, response validity). No hedged race on purpose — this - * path is latency-insensitive. - */ - @Nullable - private static String runQueryRequest(String state, String body, long deadlineNanos) - throws JSONException { - String text = null; - while (true) { - state = FlowBridge.nextQueryStep(state, text); - if (state == null) { - return null; - } - JSONObject step = new JSONObject(state); - if (step.optBoolean("found")) { - return text; - } - String url = step.optString("url", ""); - if (url.isEmpty()) { - return null; - } - text = httpRequest(url, step.optBoolean("post") ? body : null, deadlineNanos); - } - } - - private static long capToHoldBudget(long phaseDeadlineNanos, long holdDeadlineNanos) { - if (holdDeadlineNanos <= 0) { - return phaseDeadlineNanos; - } - return Math.min(phaseDeadlineNanos, holdDeadlineNanos); - } - /** Queues one download attempt; null when it was rejected up front (the listener was told). */ interface DownloadStarter { @Nullable DownloadTaskParams start( - String type, String url, UpdateContext.DownloadFileListener listener, long deadlineNanos); + String strategy, String url, String hash, @Nullable String originHash, + UpdateContext.DownloadFileListener listener, long deadlineNanos); } - private static DownloadStarter downloadStarter( - final UpdateContext context, final String hash, final String originHash - ) { - return (type, url, listener, deadlineNanos) -> { - if ("diff".equals(type)) { + private static DownloadStarter downloadStarter(final UpdateContext context) { + return (strategy, url, hash, originHash, listener, deadlineNanos) -> { + if ("diff".equals(strategy)) { return context.downloadPatchFromPpk(url, hash, originHash, listener, deadlineNanos); } - if ("pdiff".equals(type)) { + if ("pdiff".equals(strategy)) { return context.downloadPatchFromApk(url, hash, listener, deadlineNanos); } return context.downloadFullUpdate(url, hash, listener, deadlineNanos); @@ -603,110 +603,56 @@ private static DownloadStarter downloadStarter( } /** - * Strategies in the core's order (diff, pdiff, full), each over its - * mirrors, already planned for this device (FlowBridge.handleResponse: - * no diff without a running version, no cleartext URL under an - * https-only config). + * One download attempt the engine asked for, waited on up to its + * deadline: {"type": "download", ok, code?, timedOut?}. */ - static boolean performAttempts( - @Nullable JSONArray attempts, long holdDeadlineNanos, DownloadStarter starter - ) { - if (attempts == null) { - return false; - } - final long incrementalDeadlineNanos = capToHoldBudget( - System.nanoTime() + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS), - holdDeadlineNanos); - long fullDeadlineNanos = 0; - for (int i = 0; i < attempts.length(); i++) { - JSONObject attempt = attempts.optJSONObject(i); - if (attempt == null) { - continue; - } - String type = attempt.optString("type"); - final boolean isFullAttempt = !"diff".equals(type) && !"pdiff".equals(type); - if (isFullAttempt && fullDeadlineNanos == 0) { - // Incremental failures must not consume the last-resort full - // download's budget. Each phase gets one bounded 10min window. - fullDeadlineNanos = capToHoldBudget( - System.nanoTime() - + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS), - holdDeadlineNanos); + static JSONObject download(JSONObject command, DownloadStarter starter) throws JSONException { + final String strategy = command.getString("strategy"); + final long deadlineNanos = engineNanos(command.getDouble("deadline")); + final CountDownLatch latch = new CountDownLatch(1); + final AtomicBoolean succeeded = new AtomicBoolean(false); + final AtomicReference failure = new AtomicReference<>(); + UpdateContext.DownloadFileListener listener = new UpdateContext.DownloadFileListener() { + @Override + public void onDownloadCompleted(DownloadTaskParams params) { + succeeded.set(true); + latch.countDown(); } - final long deadlineNanos = isFullAttempt - ? fullDeadlineNanos : incrementalDeadlineNanos; - JSONArray urls = attempt.optJSONArray("urls"); - if (urls == null) { - continue; - } - for (int j = 0; j < urls.length(); j++) { - String url = urls.optString(j, ""); - // Check before enqueueing: once the phase budget is gone we - // must not launch an orphan download that outlives the round. - long remainingNanos = deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0) { - if (isFullAttempt) { - return false; - } - break; - } - final CountDownLatch latch = new CountDownLatch(1); - final AtomicBoolean succeeded = new AtomicBoolean(false); - final AtomicReference failure = new AtomicReference<>(); - final String attemptType = type; - UpdateContext.DownloadFileListener listener = - new UpdateContext.DownloadFileListener() { - @Override - public void onDownloadCompleted(DownloadTaskParams params) { - succeeded.set(true); - latch.countDown(); - } - @Override - public void onDownloadFailed(Throwable error) { - Log.i(Texts.LOG_TAG, "native sync: " + attemptType - + " attempt failed: " + error); - failure.set(error); - latch.countDown(); - } - }; - DownloadTaskParams task = starter.start(type, url, listener, deadlineNanos); - try { - if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) { - Log.w(Texts.LOG_TAG, - "native sync: download phase timed out during " + type); - // The task shares one download thread with the next - // attempt: cancel its transfer (or keep it from - // starting) instead of queueing behind it - // (CODE_AUDIT 2.12). - if (task != null) { - task.cancel(); - } - if (isFullAttempt) { - return false; - } - break; - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - if (task != null) { - task.cancel(); - } - return false; - } - if (succeeded.get()) { - return true; - } - Throwable error = failure.get(); - if (error != null - && !ErrorCodes.isMirrorRetryable(ErrorCodes.forDownloadFailure(error))) { - // The bytes arrived but could not be applied: every - // mirror serves the same artifact, so move on to the - // next strategy instead of downloading it again. - break; - } + @Override + public void onDownloadFailed(Throwable error) { + Log.i(Texts.LOG_TAG, "native sync: " + strategy + " attempt failed: " + error); + failure.set(error); + latch.countDown(); } + }; + DownloadTaskParams task = starter.start( + strategy, command.getString("url"), command.getString("hash"), + command.has("originHash") ? command.getString("originHash") : null, + listener, deadlineNanos); + JSONObject event = new JSONObject().put("type", "download"); + boolean settled; + try { + settled = latch.await(deadlineNanos - System.nanoTime(), TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + settled = false; + } + if (!settled) { + Log.w(Texts.LOG_TAG, "native sync: download phase timed out during " + strategy); + // The task shares one download thread with the next attempt: + // cancel its transfer (or keep it from starting) instead of + // queueing behind it (CODE_AUDIT 2.12). + if (task != null) { + task.cancel(); + } + return event.put("timedOut", true); + } + event.put("ok", succeeded.get()); + Throwable error = failure.get(); + if (error != null) { + event.put("code", ErrorCodes.forDownloadFailure(error)); } - return false; + return event; } } diff --git a/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java index 413139a0..a0474f79 100644 --- a/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java @@ -6,29 +6,42 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import org.json.JSONArray; +import java.util.concurrent.TimeUnit; import org.json.JSONObject; import org.junit.Test; +// The round's policy (order, mirror retry, budgets, commit and result +// mapping) is the shared engine's, pinned by +// cpp/update_flow_core/tests/round-engine-vectors.json. These cover what the +// Android adapter adds: running one download command and reporting it back. public class SyncCoordinatorTest { - /** Settles every attempt synchronously from a per-url script. */ + /** Settles (or, with no outcome, never settles) each attempt synchronously. */ private static final class ScriptedStarter implements SyncCoordinator.DownloadStarter { final List started = new ArrayList<>(); - private final java.util.Map failures = new java.util.HashMap<>(); + private final Throwable error; + private final boolean settles; + boolean cancelled; - ScriptedStarter fail(String url, Throwable error) { - failures.put(url, error); - return this; + ScriptedStarter(Throwable error, boolean settles) { + this.error = error; + this.settles = settles; } @Override public DownloadTaskParams start( - String type, String url, UpdateContext.DownloadFileListener listener, long deadlineNanos + String strategy, String url, String hash, String originHash, + UpdateContext.DownloadFileListener listener, long deadlineNanos ) { - started.add(type + " " + url); - Throwable error = failures.get(url); + started.add(strategy + " " + url + " " + hash + " " + originHash); + if (!settles) { + return new DownloadTaskParams() { + @Override + void cancel() { + cancelled = true; + } + }; + } if (error != null) { listener.onDownloadFailed(error); } else { @@ -38,59 +51,56 @@ public DownloadTaskParams start( } } - private static JSONArray attempts(Object[]... attempts) throws Exception { - JSONArray result = new JSONArray(); - for (Object[] attempt : attempts) { - JSONObject entry = new JSONObject(); - entry.put("type", attempt[0]); - entry.put("urls", new JSONArray(Arrays.asList(attempt).subList(1, attempt.length))); - result.put(entry); + private static JSONObject command(String strategy, long budgetMillis) throws Exception { + JSONObject command = new JSONObject() + .put("type", "download") + .put("strategy", strategy) + .put("url", "https://a/" + strategy) + .put("hash", "h2") + .put("deadline", SyncCoordinator.engineMillis( + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMillis))); + if ("diff".equals(strategy)) { + command.put("originHash", "cur"); } - return result; + return command; } - private static PatchFailedException patchFailed() { - return new PatchFailedException("hpatch failed", null); + @Test + public void aSettledAttemptReportsOk() throws Exception { + ScriptedStarter starter = new ScriptedStarter(null, true); + JSONObject event = SyncCoordinator.download(command("diff", 10_000), starter); + + assertTrue(event.getBoolean("ok")); + assertFalse(event.has("code")); + assertEquals("diff https://a/diff h2 cur", starter.started.get(0)); } @Test - public void patchFailureSkipsTheStrategysRemainingMirrors() throws Exception { - ScriptedStarter starter = new ScriptedStarter() - .fail("https://a/diff", patchFailed()) - .fail("https://a/full", new IOException("reset")); - boolean downloaded = SyncCoordinator.performAttempts( - attempts( - new Object[] {"diff", "https://a/diff", "https://b/diff"}, - new Object[] {"full", "https://a/full", "https://b/full"}), - 0, starter); + public void aFailureReportsItsErrorCode() throws Exception { + JSONObject patch = SyncCoordinator.download(command("pdiff", 10_000), + new ScriptedStarter(new PatchFailedException("hpatch failed", null), true)); + JSONObject network = SyncCoordinator.download(command("full", 10_000), + new ScriptedStarter(new IOException("reset"), true)); - assertTrue(downloaded); - assertEquals( - Arrays.asList("diff https://a/diff", "full https://a/full", "full https://b/full"), - starter.started); + assertFalse(patch.getBoolean("ok")); + assertEquals("PATCH_FAILED", patch.getString("code")); + assertEquals("DOWNLOAD_FAILED", network.getString("code")); } @Test - public void retryableFailuresTryEveryMirror() throws Exception { - ScriptedStarter starter = new ScriptedStarter() - .fail("https://a/full", new IOException("timeout")) - .fail("https://b/full", new FileOperationException("refused")); - boolean downloaded = SyncCoordinator.performAttempts( - attempts(new Object[] {"full", "https://a/full", "https://b/full"}), - 0, starter); + public void anAttemptPastItsDeadlineIsCancelledAndTimedOut() throws Exception { + ScriptedStarter starter = new ScriptedStarter(null, false); + JSONObject event = SyncCoordinator.download(command("full", 50), starter); - assertFalse(downloaded); - assertEquals( - Arrays.asList("full https://a/full", "full https://b/full"), starter.started); + assertTrue(event.getBoolean("timedOut")); + assertTrue(starter.cancelled); + assertEquals("full https://a/full h2 null", starter.started.get(0)); } - // Attempt planning (diff needs a running version, https-only rounds drop - // cleartext URLs) and the https-only rule live in the shared core: - // cpp/update_flow_core/tests/native-round-vectors.json. - @Test public void onlyPatchFailuresAreNotMirrorRetryable() { - assertEquals("PATCH_FAILED", ErrorCodes.forDownloadFailure(patchFailed())); + assertEquals("PATCH_FAILED", + ErrorCodes.forDownloadFailure(new PatchFailedException("x", null))); assertEquals("FILE_OPERATION_FAILED", ErrorCodes.forDownloadFailure(new FileOperationException("x"))); assertEquals("DOWNLOAD_FAILED", ErrorCodes.forDownloadFailure(new IOException("x"))); diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index 6e195a97..46d29b15 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -22,6 +22,7 @@ jobjectArray ApplyArchiveDelta(JNIEnv* env, jclass, jint patch_type, jobjectArra void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jobjectArray keep_names, jint max_age_days, jstring trash_dir); jstring FlowPrepareRound(JNIEnv* env, jclass, jstring configJson, jstring envJson, jdouble randomSample); jstring FlowNextQueryStep(JNIEnv* env, jclass, jstring stateJson, jstring responseText); +jstring FlowStepRound(JNIEnv* env, jclass, jstring stateJson, jstring eventJson); jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring configJson, jstring envJson, jstring requestBody, jlong responseAtSeconds); jboolean FlowConfigsEquivalent(JNIEnv* env, jclass, jstring a, jstring b); jstring FlowRunnableConfig(JNIEnv* env, jclass, jstring configJson); diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index e5a7546c..a3758944 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -53,6 +53,8 @@ const std::vector& NativeClasses() { reinterpret_cast(&pushy::jni_natives::FlowPrepareRound)}, {"3412ecc59f9e6d573b0c08fcc6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2e4af83897d163a12fecae5b4705357351fae", reinterpret_cast(&pushy::jni_natives::FlowNextQueryStep)}, + {"2903f1c19c847d4b26", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2e4af83897d163a12fecae5b4705357351fae", + reinterpret_cast(&pushy::jni_natives::FlowStepRound)}, {"3216fad5a28e5a40312f13f7c5b6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d595959c72462c45ebc5afb9d44b412006e2cefd", reinterpret_cast(&pushy::jni_natives::FlowHandleResponse)}, {"3918fad7a78c7b60332a15efd7bf95635e", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2f2", diff --git a/cpp/android_jni/update_flow_jni.cpp b/cpp/android_jni/update_flow_jni.cpp index 6592a1ed..d0cb11ba 100644 --- a/cpp/android_jni/update_flow_jni.cpp +++ b/cpp/android_jni/update_flow_jni.cpp @@ -14,6 +14,7 @@ #include "../update_flow_core/flow_json.h" #include "../update_flow_core/native_config.h" #include "../update_flow_core/native_round.h" +#include "../update_flow_core/round_engine.h" using pushy::jni_util::ToJString; @@ -50,6 +51,22 @@ jstring pushy::jni_natives::FlowPrepareRound(JNIEnv* env, jclass, } } +// The round driver (update_flow_core/round_engine.h). A null state starts +// fresh; input that does not parse finishes the round inside the core, so +// only an exception yields null. +jstring pushy::jni_natives::FlowStepRound(JNIEnv* env, jclass, + jstring stateJson, + jstring eventJson) { + try { + return ToJString( + env, flowcore::StepRoundJson( + pushy::jni_util::JStringToString(env, stateJson), + pushy::jni_util::JStringToString(env, eventJson))); + } catch (...) { + return nullptr; + } +} + // A null responseText is a failed request (or none made yet). jstring pushy::jni_natives::FlowNextQueryStep(JNIEnv* env, jclass, jstring stateJson, diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index 20b6c67e..79ce2a99 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -628,7 +628,7 @@ full 超时整体失败)、激活判定(`activate || crashHold || forceActiv 由 `tests/round-engine-vectors.json` 锁定,接入各端时随之生效: - 宿主取消判定:鸿蒙原用字符串全等比较配置,改为 C1 等价(与 Android/iOS 一致)。 -- 提交带配置代数:iOS 早已在锁内复核;Android/鸿蒙接入时补上(原先只在宿主等待后检查)。 +- 提交带配置代数:iOS 在锁内复核两个代数;Android 换配置时同时递增 reset 代数,提交天然被拒,适配器再按配置代数回报 `config_changed`;鸿蒙接入时对齐。 - HTTP 超时:三端统一为空闲 10s + 整次 15s,并按预算钳制(iOS 原先的信号量兜底会比预算多等 5s)。 - 下载超时:显式 `timedOut`,不再依赖鸿蒙把超时包装成 DOWNLOAD_FAILED 后由下一次循环看 deadline。 - 只提交缓存的提交失败统一为 `failed/commit_failed`(Android 原为 internal_error)。 @@ -645,4 +645,5 @@ full 超时整体失败)、激活判定(`activate || crashHold || forceActiv 进度:1、2 已合入(#673、#674)。iOS 已接入:`RCTPushyOrchestrator.mm` 的 `runOnce` 驱动引擎, 命令由 `perform:` / `download:` / `commit:` 执行,宿主取消判定改用 `HostCancellation`;提交败于配置 -变更时由适配器按配置代数回报 `reason: "config_changed"`。 +变更时由适配器按配置代数回报 `reason: "config_changed"`。Android 已接入:`SyncCoordinator.java` 的 `runOnce` 经 `FlowBridge.stepRound`(JNI)驱动引擎, +`download` 执行单次下载并回报 ok/code/timedOut(JVM 单测覆盖),`android/lib` 的 `.so` 随之重编。 diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index a2e39084..ea80eed1 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -75,7 +75,7 @@ const registered = () => { describe('JNI registration table', () => { test('matches every Java native declaration', () => { const natives = javaNatives(); - expect(natives.length).toBe(18); + expect(natives.length).toBe(19); expect(registered()).toEqual(natives); });