From fc0ee4b7afb7dab57c80faa4bf60d7a2a7c4ccf6 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 08:18:31 +0800 Subject: [PATCH 1/2] feat(cpp): sans-IO round engine for the native cold-start round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three orchestrators (Android SyncCoordinator.java, iOS RCTPushyOrchestrator.mm, Harmony SyncCoordinator.ts) each hand-write the loop around the shared round steps: config gate, breadcrumb, endpoint query, response decision, installed check, the two download budgets, the commit and the result mapping. The vectors pinned the steps but not the loop, and the loop is where the drift kept turning up. cpp/update_flow_core/round_engine.h adds StepRound(state, event) -> {state, commands}: a pure function over JSON values (no IO, no clock, no locks; time arrives in the events), plus StepRoundJson for the JNI/NAPI bindings, HostCancellation for the host's post-wait check and IsSafePathComponent. tests/round-engine-vectors.json pins 32 event traces (no update, fallback, remote lists, activation policies, mirror retry, budgets, crash hold, reset/config cancellation, commit failure, protocol errors); the C++ test replays them through both APIs, and the fuzz target drives whole rounds over fuzzed responses. No platform code changes; NATIVE_CHECKUPDATE_DESIGN §12 describes the contract, the deliberate alignments and the rollout. Co-Authored-By: Claude Opus 5.5 --- cpp/update_flow_core/native_round.h | 4 +- cpp/update_flow_core/round_engine.cpp | 455 +++ cpp/update_flow_core/round_engine.h | 105 + cpp/update_flow_core/tests/fuzz_flow_json.cpp | 42 + .../tests/round-engine-vectors.json | 2996 +++++++++++++++++ .../tests/update_flow_core_test.cpp | 133 +- docs/NATIVE_CHECKUPDATE_DESIGN.md | 89 + scripts/test-update-flow-core.sh | 3 +- 8 files changed, 3817 insertions(+), 10 deletions(-) create mode 100644 cpp/update_flow_core/round_engine.cpp create mode 100644 cpp/update_flow_core/round_engine.h create mode 100644 cpp/update_flow_core/tests/round-engine-vectors.json diff --git a/cpp/update_flow_core/native_round.h b/cpp/update_flow_core/native_round.h index ea5c4b80..32f3daa0 100644 --- a/cpp/update_flow_core/native_round.h +++ b/cpp/update_flow_core/native_round.h @@ -7,8 +7,8 @@ // The pure steps of the native cold-start round (NATIVE_CHECKUPDATE_DESIGN // §10), one implementation for the Android, iOS and Harmony orchestrators: // what to send, which endpoint to ask next, and everything a commit needs -// from the answer. HTTP, timeouts, locks, downloads and persistence stay -// native. JS has no counterpart (its client builds its own request through +// from the answer. round_engine.h drives these steps; HTTP, locks, +// downloads and persistence stay native. JS has no counterpart (its client builds its own request through // src/updateFlowCore.ts buildCheckRequestBody, which BuildRequestBody // mirrors); tests/native-round-vectors.json pins this layer. // diff --git a/cpp/update_flow_core/round_engine.cpp b/cpp/update_flow_core/round_engine.cpp new file mode 100644 index 00000000..90aa197d --- /dev/null +++ b/cpp/update_flow_core/round_engine.cpp @@ -0,0 +1,455 @@ +#include "round_engine.h" + +#include +#include + +#include "../patch_core/obscured_text.h" +#include "native_config.h" +#include "native_round.h" +#include "update_flow_core.h" + +namespace flowcore { + +using flowjson::Value; + +namespace { + +// Stored encoded (see obscured_text.h): the result status of a round that +// found nothing to install, and the version-info flag of a version a crash +// hold installed (read by JS). +const std::string& StatusNone() { + static const std::string value = pushy::text::Reveal("3418c1c1aa8a7c40"); + return value; +} +const std::string& InfoCrashHold() { + static const std::string value = + pushy::text::Reveal("3905f5c2a6b96d56212a19"); + return value; +} + +// Phases: what the round waits for. +const char kAwaitEnv[] = "env"; +const char kAwaitHttp[] = "http"; +const char kAwaitInstalled[] = "installed"; +const char kAwaitDownload[] = "download"; +const char kAwaitCommit[] = "committed"; +const char kDone[] = "done"; + +double Number(const Value& value) { + const double number = value.AsNumber(); + return value.IsNumber() && std::isfinite(number) ? number : 0; +} + +// A non-negative integral cursor; anything else reads as 0. +size_t Index(const Value& value) { + const double number = Number(value); + return number < 0 ? 0 : number >= 1e15 ? static_cast(1e15) + : static_cast(number); +} + +Value Command(const char* type) { + Value command = Value::Object(); + command.Set("type", Value::String(type)); + return command; +} + +bool IsIncremental(const std::string& strategy) { + return strategy == "diff" || strategy == "pdiff"; +} + +// One step in progress: the state being built and the commands emitted. +class Step { + public: + Step(Value state, const Value& event) : state_(std::move(state)), event_(event) { + now_ = Number(event.Get("now")); + const std::string type = event.Get("type").AsString(); + if (type == "start" || !state_.IsObject()) { + // A start begins a new round whatever came before; any other event + // without a round finds no phase that expects it. + state_ = Value::Object(); + } + // Once a crash is held or the launch waits on the round, it stays so. + for (const char* flag : {"crashHold", "forceActivate"}) { + if (event.Get(flag).Truthy()) { + state_.Set(flag, Value::Bool(true)); + } + } + } + + Value Run() { + const std::string type = event_.Get("type").AsString(); + if (type == "start") { + Start(); + } else if (Phase() == kDone) { + // A finished round ignores late events. + return Result(); + } else if (type != Phase()) { + Finish("failed", "internal_error"); + } else if (type == kAwaitEnv) { + OnEnv(); + } else if (type == kAwaitHttp) { + OnHttp(); + } else if (type == kAwaitInstalled) { + OnInstalled(); + } else if (type == kAwaitDownload) { + OnDownload(); + } else { + OnCommitted(); + } + return Result(); + } + + private: + std::string Phase() const { return state_.Get("phase").AsString(); } + void Await(const char* phase) { state_.Set("phase", Value::String(phase)); } + void Emit(Value command) { commands_.Push(std::move(command)); } + + Value Result() { + Value result = Value::Object(); + result.Set("state", std::move(state_)); + result.Set("commands", std::move(commands_)); + return result; + } + + void Finish(const std::string& status, const std::string& reason, + const Value& hash = Value::Undefined(), + const Value& activated = Value::Undefined()) { + Value finish = Command("finish"); + finish.Set("status", Value::String(status)); + finish.Set("reason", Value::String(reason)); + finish.Set("hash", hash); + finish.Set("activated", activated); + Emit(std::move(finish)); + Await(kDone); + } + + // What the remaining round budget allows for a bound of `limit` ms; + // `limit` itself without a deadline. + double Within(double limit) const { + const double deadline = Number(state_.Get("deadline")); + return deadline > 0 ? std::fmin(limit, deadline - now_) : limit; + } + + // A deadline `span` ms from now, capped by the round's. + double DeadlineIn(double span) const { + const double deadline = Number(state_.Get("deadline")); + return deadline > 0 ? std::fmin(now_ + span, deadline) : now_ + span; + } + + void Start() { + const Value& config = event_.Get("config"); + state_.Set("config", config.IsString() ? config : Value::String("")); + for (const char* key : + {"resetGeneration", "configGeneration", "sample", "deadline"}) { + state_.Set(key, Value::Number(Number(event_.Get(key)))); + } + const RunnableConfig gate = + ParseRunnableConfig(state_.Get("config").AsString()); + if (!gate.runnable()) { + // Not a round: nothing ran, so nothing to mark. + Finish(gate.status, gate.reason); + return; + } + Emit(Command("markIncomplete")); + Emit(Command("collectEnv")); + Await(kAwaitEnv); + } + + void OnEnv() { + const Value& env = event_.Get("env"); + Value round = PrepareRound(state_.Get("config").AsString(), env, + Number(state_.Get("sample"))); + if (!round.IsObject()) { + Finish("failed", "invalid_request"); + return; + } + state_.Set("env", env); + state_.Set("body", round.Get("body")); + state_.Set("query", round.Get("query")); + Query(Value::Undefined()); + } + + // Asks the next endpoint, or settles the query on `text`. + void Query(const Value& text) { + Value query = NextQueryStep(state_.Get("query"), text); + if (query.Get("found").Truthy()) { + state_.Remove("query"); + Respond(text.AsString()); + return; + } + const Value& url = query.Get("url"); + const double timeout = Within(kQueryIdleTimeoutMs); + if (!url.IsString() || timeout <= 0) { + // No endpoint answered (or the budget is gone): nothing to commit. + Finish("failed", "check_failed"); + return; + } + Value http = Command("http"); + http.Set("url", url); + const bool post = query.Get("post").Truthy(); + http.Set("post", Value::Bool(post)); + if (post) { + http.Set("body", state_.Get("body")); + } + http.Set("timeoutMs", Value::Number(timeout)); + http.Set("callTimeoutMs", Value::Number(Within(kQueryCallTimeoutMs))); + Emit(std::move(http)); + state_.Set("query", std::move(query)); + Await(kAwaitHttp); + } + + void OnHttp() { + const Value& text = event_.Get("text"); + state_.Set("at", Value::Number(std::floor(Number(event_.Get("at"))))); + Query(text.IsString() ? text : Value::Undefined()); + } + + void Respond(const std::string& text) { + Value decision = HandleRoundResponse( + text, state_.Get("config").AsString(), state_.Get("env"), + state_.Get("body").AsString(), Number(state_.Get("at"))); + state_.Remove("at"); + if (!decision.IsObject()) { + Finish("failed", "invalid_response"); + return; + } + state_.Set("cache", decision.Get("cache")); + if (decision.Get("action").AsString() != "download") { + CommitCache(StatusNone(), decision.Get("reason").AsString()); + return; + } + const Value hash = decision.Get("hash"); + if (!hash.IsString() || !IsSafePathComponent(hash.AsString())) { + Finish("failed", "invalid_response"); + return; + } + decision.Remove("action"); + decision.Remove("cache"); + state_.Set("decision", std::move(decision)); + Value check = Command("checkInstalled"); + check.Set("hash", hash); + Emit(std::move(check)); + Await(kAwaitInstalled); + } + + void OnInstalled() { + if (event_.Get("installed").Truthy()) { + CommitVersion(); + return; + } + Value cursor = Value::Object(); + cursor.Set("attempt", Value::Number(0)); + cursor.Set("url", Value::Number(0)); + cursor.Set("incremental", Value::Number(DeadlineIn(kDownloadPhaseMs))); + cursor.Set("full", Value::Number(0)); + state_.Set("cursor", std::move(cursor)); + NextAttempt(); + } + + // Strategies in the planned order (diff, pdiff, full), each over its + // mirrors, starting at the cursor. + void NextAttempt() { + Value cursor = state_.Get("cursor"); + const Value& attempts = state_.Get("decision").Get("attempts"); + size_t attempt = Index(cursor.Get("attempt")); + size_t url = Index(cursor.Get("url")); + for (; attempt < attempts.Size(); attempt++, url = 0) { + const Value& planned = attempts.At(attempt); + const std::string strategy = planned.Get("type").AsString(); + const bool full = !IsIncremental(strategy); + if (full && Number(cursor.Get("full")) == 0) { + cursor.Set("full", Value::Number(DeadlineIn(kDownloadPhaseMs))); + } + const double deadline = + Number(cursor.Get(full ? "full" : "incremental")); + const Value& urls = planned.Get("urls"); + if (url >= urls.Size()) { + continue; + } + // Never start an attempt whose phase budget is gone: it would outlive + // the round. The full phase is the last one. + if (deadline - now_ <= 0) { + if (full) { + break; + } + continue; + } + Value download = Command("download"); + download.Set("strategy", Value::String(full ? "full" : strategy)); + download.Set("url", urls.At(url)); + download.Set("hash", state_.Get("decision").Get("hash")); + if (strategy == "diff") { + download.Set("originHash", state_.Get("env").Get("currentVersion")); + } + download.Set("deadline", Value::Number(deadline)); + Emit(std::move(download)); + cursor.Set("attempt", Value::Number(static_cast(attempt))); + cursor.Set("url", Value::Number(static_cast(url))); + state_.Set("cursor", std::move(cursor)); + Await(kAwaitDownload); + return; + } + state_.Remove("cursor"); + // The native attempt is over, so JS may reuse the response and retry + // through its own strategy chain. + CommitCache("failed", "download_failed"); + } + + void OnDownload() { + if (event_.Get("ok").Truthy()) { + state_.Remove("cursor"); + CommitVersion(); + return; + } + Value cursor = state_.Get("cursor"); + const size_t attempt = Index(cursor.Get("attempt")); + const std::string strategy = state_.Get("decision") + .Get("attempts") + .At(attempt) + .Get("type") + .AsString(); + const bool nextStrategy = + event_.Get("timedOut").Truthy() || + // The bytes arrived but could not be applied: every mirror serves + // the same artifact, so only the next strategy can help. + !IsMirrorRetryableCode(event_.Get("code").AsString()); + if (event_.Get("timedOut").Truthy() && !IsIncremental(strategy)) { + // The full phase timed out: no budget is left for anything. + cursor.Set("attempt", Value::Number(1e15)); + } else if (nextStrategy) { + cursor.Set("attempt", Value::Number(static_cast(attempt + 1))); + cursor.Set("url", Value::Number(0)); + } else { + cursor.Set("url", Value::Number(Number(cursor.Get("url")) + 1)); + } + state_.Set("cursor", std::move(cursor)); + NextAttempt(); + } + + // Only the response cache: nothing to install or activate. + void CommitCache(const std::string& status, const std::string& reason) { + Value result = Value::Object(); + result.Set("status", Value::String(status)); + result.Set("reason", Value::String(reason)); + Commit("", "", false, std::move(result)); + } + + // The version info, the activation and the response cache in one commit. + void CommitVersion() { + const Value& decision = state_.Get("decision"); + const bool crashHold = state_.Get("crashHold").Truthy(); + Value hashInfo = decision.Get("hashInfo"); + if (crashHold) { + hashInfo.Set(InfoCrashHold(), Value::Bool(true)); + } + // Silent strategies or a server-marked forceBoot version activate for + // the next launch; otherwise activation stays with JS — unless JS is + // dead (a crash hold) or the launch itself waits on this round. + const bool activate = decision.Get("activate").Truthy() || crashHold || + state_.Get("forceActivate").Truthy(); + const std::string hash = decision.Get("hash").AsString(); + Value result = Value::Object(); + result.Set("status", Value::String("downloaded")); + result.Set("reason", Value::String("")); + result.Set("hash", Value::String(hash)); + result.Set("activated", Value::Bool(activate)); + Commit(hash, flowjson::Stringify(hashInfo), activate, std::move(result)); + } + + void Commit(const std::string& hash, const std::string& hashInfo, + bool activate, Value result) { + Value commit = Command("commit"); + commit.Set("hash", Value::String(hash)); + commit.Set("hashInfo", Value::String(hashInfo)); + commit.Set("activate", Value::Bool(activate)); + commit.Set("cache", state_.Get("cache")); + commit.Set("resetGeneration", state_.Get("resetGeneration")); + commit.Set("configGeneration", state_.Get("configGeneration")); + Emit(std::move(commit)); + state_.Set("result", std::move(result)); + Await(kAwaitCommit); + } + + void OnCommitted() { + const Value result = state_.Get("result"); + if (event_.Get("error").Truthy()) { + Finish("failed", "commit_failed"); + } else if (!event_.Get("ok").Truthy()) { + Finish("cancelled", "reset"); + } else if (result.Get("hash").IsString()) { + const Value& activated = event_.Get("activated"); + Finish(result.Get("status").AsString(), result.Get("reason").AsString(), + result.Get("hash"), + activated.IsBool() ? activated : result.Get("activated")); + } else { + Finish(result.Get("status").AsString(), result.Get("reason").AsString()); + } + } + + Value state_; + const Value& event_; + double now_ = 0; + Value commands_ = Value::Array(); +}; + +} // namespace + +bool IsSafePathComponent(const std::string& name) { + return !name.empty() && name != "." && name != ".." && + name.find_first_of(std::string("/\\\0", 3)) == std::string::npos; +} + +Value StepRound(const Value& state, const Value& event) { + return Step(state, event).Run(); +} + +std::string StepRoundJson(const std::string& stateJson, + const std::string& eventJson) { + // The state carries server text (the response cache, the planned URLs) and + // the event a whole response body, so the byte cap is the platform's + // response cap's business; the node cap still bounds the heap. + flowjson::ParseLimits limits; + limits.max_input_bytes = 256 * 1024 * 1024; + limits.max_nodes = 4 * flowjson::kMaxNodes; + bool stateOk = true; + bool eventOk = false; + const Value state = + stateJson.empty() ? Value::Undefined() + : flowjson::Parse(stateJson, limits, &stateOk); + const Value event = flowjson::Parse(eventJson, limits, &eventOk); + if (!stateOk || !eventOk || !event.IsObject()) { + Value finish = Value::Object(); + finish.Set("type", Value::String("finish")); + finish.Set("status", Value::String("failed")); + finish.Set("reason", Value::String("internal_error")); + Value commands = Value::Array(); + commands.Push(std::move(finish)); + Value done = Value::Object(); + done.Set("phase", Value::String(kDone)); + Value result = Value::Object(); + result.Set("state", std::move(done)); + result.Set("commands", std::move(commands)); + return flowjson::Stringify(result); + } + return flowjson::Stringify(StepRound(state, event)); +} + +std::string HostCancellation(const std::string& hostConfig, + const std::string& roundConfig, + const std::string& storedConfig, + double roundConfigGeneration, + double configGeneration, + double roundResetGeneration, + double resetGeneration) { + if (roundConfigGeneration != configGeneration || + !ConfigsEquivalent(hostConfig, roundConfig) || + !ConfigsEquivalent(hostConfig, storedConfig)) { + return "config_changed"; + } + if (roundResetGeneration != resetGeneration) { + return "reset"; + } + return ""; +} + +} // namespace flowcore diff --git a/cpp/update_flow_core/round_engine.h b/cpp/update_flow_core/round_engine.h new file mode 100644 index 00000000..0b724e6d --- /dev/null +++ b/cpp/update_flow_core/round_engine.h @@ -0,0 +1,105 @@ +#pragma once + +#include + +#include "flow_json.h" + +// The driver of one native cold-start round (NATIVE_CHECKUPDATE_DESIGN §12): +// the loop the Android, iOS and Harmony orchestrators used to write by hand +// around the pure steps of native_round.h — the config gate, the breadcrumb, +// the endpoint query, the response decision, the already-installed check, the +// two download budgets, the commit and the result it maps to. +// +// Sans-IO: StepRound takes the previous state and what just happened, and +// returns the next state plus what to do. It never does IO, never reads a +// clock, never locks; the time arrives in each event. The platform adapter +// runs the commands (HTTP, downloads, persistence, the commit's lock and its +// generation re-check, the activation digest gate) and feeds the outcome back +// as the next event. The state is plain JSON, so the JNI and NAPI bindings +// pass it through as text and keep no handle. +// +// tests/round-engine-vectors.json pins every trace. +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace flowcore { + +// Each download phase's window: the incremental strategies (diff, pdiff) +// share one, the first full attempt opens its own, so incremental failures +// cannot starve the last-resort full download. A round deadline caps both. +constexpr double kDownloadPhaseMs = 600 * 1000; +// Per request of the endpoint query: the idle (connect/read) timeout and the +// whole-call bound. A round deadline caps both. +constexpr double kQueryIdleTimeoutMs = 10 * 1000; +constexpr double kQueryCallTimeoutMs = 15 * 1000; + +// One step of the round. `state` is the previous result's "state" (anything +// for a "start" event); the result is {"state": ..., "commands": [...]}. +// +// Events ({"type": ..., ...}; every event carries "now", the platform's +// monotonic clock in milliseconds, and may carry the sticky flags +// "crashHold" — a crash is being held, JS is dead — and "forceActivate" — +// the launch waits on this round, as the tvOS purge restore): +// start config (the persisted config text, "" when absent), +// resetGeneration, configGeneration (sampled before the config +// was read), sample (∈ [0, 1), picks the first endpoint), +// deadline (monotonic ms; 0 for none) +// env env: the launch as native_round.h describes it +// http text: the response body (absent when the request failed), +// at: wall-clock seconds the response arrived +// installed installed: a complete install of the hash is already on disk +// download ok; code: the failure's error code; timedOut: the attempt +// outlived its deadline +// committed ok: the commit landed (false: a reset or a config change +// since the round started won); activated: what it really +// activated (absent = as asked); error: the commit itself failed +// (the activation gate rejected the version, a write threw) +// +// Commands: +// markIncomplete persist the interrupted-round breadcrumb (§11.4). The +// adapter clears it once the round is over, however it +// ends +// collectEnv reply with an env event +// http url, post, body (when post), timeoutMs, callTimeoutMs +// checkInstalled hash +// download strategy ("diff", "pdiff" or "full"), url, hash, +// originHash (diff only), deadline (monotonic ms) +// commit hash and hashInfo (the version info as JSON text; both "" +// when only the response cache is committed), activate, +// cache, resetGeneration, configGeneration — one atomic +// commit that first re-checks both generations +// finish status, reason, and for "downloaded" hash and activated. +// The round is over; later events are ignored. A +// downloaded but not activated version is the one a later +// crash hold may activate. +// +// An event the current phase does not expect finishes the round as +// failed/internal_error. +flowjson::Value StepRound(const flowjson::Value& state, + const flowjson::Value& event); + +// StepRound over JSON text, for the bindings. A state or event that does not +// parse finishes the round as failed/internal_error. +std::string StepRoundJson(const std::string& stateJson, + const std::string& eventJson); + +// Whether a host that waited on a round may take its result, checked after +// the wait: "" when it may, otherwise the cancellation reason. The config +// the host saw, the one the round ran with and the one stored now must be +// equivalent (ConfigsEquivalent) and the config generation unchanged +// ("config_changed"); then the reset generation unchanged ("reset"). +std::string HostCancellation(const std::string& hostConfig, + const std::string& roundConfig, + const std::string& storedConfig, + double roundConfigGeneration, + double configGeneration, + double roundResetGeneration, + double resetGeneration); + +// A server-provided hash may name a directory: non-empty, not "." or "..", +// no '/', '\\' or NUL. +bool IsSafePathComponent(const std::string& name); + +} // namespace flowcore + +#pragma GCC visibility pop diff --git a/cpp/update_flow_core/tests/fuzz_flow_json.cpp b/cpp/update_flow_core/tests/fuzz_flow_json.cpp index f6229928..8ecf4407 100644 --- a/cpp/update_flow_core/tests/fuzz_flow_json.cpp +++ b/cpp/update_flow_core/tests/fuzz_flow_json.cpp @@ -11,6 +11,7 @@ #include "../flow_json.h" #include "../native_config.h" #include "../native_round.h" +#include "../round_engine.h" #include "../update_flow_core.h" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { @@ -66,6 +67,47 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { flowjson::Stringify( flowcore::HandleRoundResponse("{\"upToDate\":true}", text, env, text, 1)); + // The round driver: the text as the response every endpoint returns, then + // as the binding's event and state. Every round must finish: one step per + // request, planned mirror (bounded by the response's node cap) and commit. + flowjson::Value engine; + flowjson::Value event = flowjson::Value::Object(); + event.Set("type", flowjson::Value::String("start")); + event.Set("config", flowjson::Value::String( + "{\"appKey\":\"k\",\"endpoints\":[\"https://a\"]}")); + for (int step = 0;; step++) { + if (step > 4 * static_cast(flowjson::kMaxNodes)) { + std::abort(); + } + const flowjson::Value result = flowcore::StepRound(engine, event); + engine = result.Get("state"); + if (engine.Get("phase").AsString() == "done") { + break; + } + const std::string type = result.Get("commands") + .At(result.Get("commands").Size() - 1) + .Get("type") + .AsString(); + event = flowjson::Value::Object(); + if (type == "collectEnv") { + event.Set("type", flowjson::Value::String("env")); + event.Set("env", env); + } else if (type == "http") { + event.Set("type", flowjson::Value::String("http")); + event.Set("text", flowjson::Value::String(text)); + } else if (type == "checkInstalled") { + event.Set("type", flowjson::Value::String("installed")); + } else if (type == "download") { + event.Set("type", flowjson::Value::String("download")); + event.Set("code", flowjson::Value::String("DOWNLOAD_FAILED")); + } else { + event.Set("type", flowjson::Value::String("committed")); + event.Set("ok", flowjson::Value::Bool(true)); + } + } + flowcore::StepRoundJson(text, "{\"type\":\"committed\",\"ok\":true}"); + flowcore::StepRoundJson("", text); + // The persisted-config layer reads host/JS-written text: equivalence must // be reflexive for every object, and a normalized config must be runnable // (or disabled) and stable under its own normalization. diff --git a/cpp/update_flow_core/tests/round-engine-vectors.json b/cpp/update_flow_core/tests/round-engine-vectors.json new file mode 100644 index 00000000..f7a37372 --- /dev/null +++ b/cpp/update_flow_core/tests/round-engine-vectors.json @@ -0,0 +1,2996 @@ +{ + "description": "The native round driver (cpp/update_flow_core/round_engine.h), shared by the Android, iOS and Harmony orchestrators and replayed by scripts/test-update-flow-core.sh. `rounds`: each step feeds `event` to StepRound with the previous step's state and expects exactly `commands` (compared as JSON, key order ignored). A string \"@name\" anywhere in a round or a host case stands for fixtures[name]. `host`: HostCancellation. `safePath`: IsSafePathComponent. `before` records what a platform did where it disagreed before the engine replaced its hand-written loop. Hand-maintained.", + "fixtures": { + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\",\"https://b.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "config.silent": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"setNeedUpdate\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "config.lists": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[\"https://l.example/list.json\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "env.fresh": { + "packageVersion": "1.0.0", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "ios 18.0" + }, + "upToDate": "{\"upToDate\":true}", + "v2": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"https://cdn1.example\",\"https://cdn2.example\"]}", + "v2.forceBoot": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"full\":\"f.ppk\",\"paths\":[\"https://cdn1.example\"],\"config\":{\"forceBoot\":true}}", + "body": "{\"packageVersion\":\"1.0.0\",\"hash\":\"cur\",\"buildTime\":\"1700000000\",\"cInfo\":{\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"os\":\"android 34\",\"uuid\":\"u-1\"},\"diffV\":2,\"bundleHash\":\"bh\"}", + "body.fresh": "{\"packageVersion\":\"1.0.0\",\"buildTime\":\"1700000000\",\"cInfo\":{\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"os\":\"ios 18.0\",\"uuid\":\"u-1\"},\"diffV\":2}" + }, + "rounds": [ + { + "name": "no config: skipped, nothing marked", + "steps": [ + { + "event": { + "type": "start", + "config": "", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "skipped", + "reason": "not_configured" + } + ] + } + ] + }, + { + "name": "disabled config: skipped", + "steps": [ + { + "event": { + "type": "start", + "config": "{\"appKey\":\"key\",\"disabled\":true}", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "skipped", + "reason": "disabled" + } + ] + } + ] + }, + { + "name": "blank appKey: invalid config", + "steps": [ + { + "event": { + "type": "start", + "config": "{\"appKey\":\" \"}", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "invalid_config" + } + ] + } + ] + }, + { + "name": "an env that is not an object: invalid request", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "x", + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "invalid_request" + } + ] + } + ] + }, + { + "name": "up to date: commit the response cache only", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@upToDate", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "commit", + "hash": "", + "hashInfo": "", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "noUpdate", + "reason": "noUpdate" + } + ] + } + ] + }, + { + "name": "up to date, then a reset wins the commit", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@upToDate", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "commit", + "hash": "", + "hashInfo": "", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": false, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "cancelled", + "reason": "reset" + } + ] + } + ] + }, + { + "name": "a failed endpoint falls back to the next", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://b.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@upToDate", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "commit", + "hash": "", + "hashInfo": "", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "noUpdate", + "reason": "noUpdate" + } + ] + } + ] + }, + { + "name": "an invalid response does not count", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "{\"error\":\"x\"}", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "http", + "url": "https://b.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@upToDate", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "commit", + "hash": "", + "hashInfo": "", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "noUpdate", + "reason": "noUpdate" + } + ] + } + ] + }, + { + "name": "every endpoint fails: check_failed, nothing committed", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://b.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "check_failed" + } + ] + } + ] + }, + { + "name": "endpoints fail, the remote list answers", + "steps": [ + { + "event": { + "type": "start", + "config": "@config.lists", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://l.example/list.json", + "post": false, + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "[\"https://r.example/\"]", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "http", + "url": "https://r.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@upToDate", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "commit", + "hash": "", + "hashInfo": "", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[\\\"https://l.example/list.json\\\"],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "noUpdate", + "reason": "noUpdate" + } + ] + } + ] + }, + { + "name": "download: diff on the first mirror, activation left to JS", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": false + } + ] + } + ] + }, + { + "name": "download with afterDownload setNeedUpdate activates", + "steps": [ + { + "event": { + "type": "start", + "config": "@config.silent", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": true, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"setNeedUpdate\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": true + } + ] + } + ] + }, + { + "name": "forceBoot activates and marks the version info", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2.forceBoot", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "full", + "url": "https://cdn1.example/f.ppk", + "hash": "h2", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"forceBootRescue\":true}", + "activate": true, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\"],\\\"config\\\":{\\\"forceBoot\\\":true}}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": true + } + ] + } + ] + }, + { + "name": "already installed: commit without downloading", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": false + } + ] + } + ] + }, + { + "name": "mirror retry: DOWNLOAD_FAILED tries the next mirror, PATCH_FAILED the next strategy", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 0, + "code": "DOWNLOAD_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn2.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 0, + "code": "PATCH_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "pdiff", + "url": "https://cdn1.example/p.hdiff", + "hash": "h2", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": false + } + ] + } + ] + }, + { + "name": "a failure without a code is mirror-retryable", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn2.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": false + } + ] + } + ] + }, + { + "name": "every attempt fails: download_failed, the cache is still committed", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 0, + "code": "DOWNLOAD_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn2.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 0, + "code": "DOWNLOAD_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "pdiff", + "url": "https://cdn1.example/p.hdiff", + "hash": "h2", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 0, + "code": "PATCH_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "full", + "url": "https://cdn1.example/f.ppk", + "hash": "h2", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 0, + "code": "DOWNLOAD_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "full", + "url": "https://cdn2.example/f.ppk", + "hash": "h2", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 0, + "code": "DOWNLOAD_FAILED" + }, + "commands": [ + { + "type": "commit", + "hash": "", + "hashInfo": "", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "download_failed" + } + ] + } + ] + }, + { + "name": "the incremental budget runs out: pdiff is skipped, full opens its own window", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 1000, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 1000 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 601000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 601000, + "timedOut": true + }, + "commands": [ + { + "type": "download", + "strategy": "full", + "url": "https://cdn1.example/f.ppk", + "hash": "h2", + "deadline": 1201000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 700000 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 700000 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": false + } + ] + } + ] + }, + { + "name": "the incremental budget spent by failures: the full window starts when full is reached", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 1000, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 1000 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 601000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 400000, + "code": "DOWNLOAD_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn2.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 601000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 601001, + "code": "DOWNLOAD_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "full", + "url": "https://cdn1.example/f.ppk", + "hash": "h2", + "deadline": 1201001 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 900000 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 900000 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": false + } + ] + } + ] + }, + { + "name": "the full download times out: download_failed", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2.forceBoot", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "full", + "url": "https://cdn1.example/f.ppk", + "hash": "h2", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 600000, + "timedOut": true + }, + "commands": [ + { + "type": "commit", + "hash": "", + "hashInfo": "", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\"],\\\"config\\\":{\\\"forceBoot\\\":true}}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 600000 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "download_failed" + } + ] + } + ] + }, + { + "name": "a reset during the download wins the commit", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": false, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "cancelled", + "reason": "reset" + } + ] + } + ] + }, + { + "name": "the commit itself fails: commit_failed", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": false, + "now": 0, + "error": true + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "commit_failed" + } + ] + } + ] + }, + { + "name": "no running version: no diff attempt, no originHash", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env.fresh", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body.fresh", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "pdiff", + "url": "https://cdn1.example/p.hdiff", + "hash": "h2", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"ios 18.0\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": false + } + ] + } + ] + }, + { + "name": "an unsafe hash is an invalid response", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "{\"update\":true,\"hash\":\"../h\",\"full\":\"f.ppk\",\"paths\":[\"https://cdn1.example\"]}", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "invalid_response" + } + ] + } + ] + }, + { + "name": "crash hold: the deadline caps requests and downloads, the version is force-activated and marked", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 8000, + "now": 1000, + "crashHold": true + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 1000 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 7000, + "callTimeoutMs": 7000 + } + ] + }, + { + "event": { + "type": "http", + "now": 4000 + }, + "commands": [ + { + "type": "http", + "url": "https://b.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 4000, + "callTimeoutMs": 4000 + } + ] + }, + { + "event": { + "type": "http", + "now": 6000, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 6000 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 8000 + } + ] + }, + { + "event": { + "type": "download", + "ok": false, + "now": 7000, + "code": "DOWNLOAD_FAILED" + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn2.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 8000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 7900 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\",\"crashRescue\":true}", + "activate": true, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 7900 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": true + } + ] + } + ] + }, + { + "name": "a crash hold that begins mid-round still force-activates", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0, + "crashHold": true + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\",\"crashRescue\":true}", + "activate": true, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": true + } + ] + } + ] + }, + { + "name": "the round deadline passes during the query: check_failed", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 5000, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 5000, + "callTimeoutMs": 5000 + } + ] + }, + { + "event": { + "type": "http", + "now": 5000 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "check_failed" + } + ] + } + ] + }, + { + "name": "a deadline already past when the download would start: download_failed", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 5000, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 5000, + "callTimeoutMs": 5000 + } + ] + }, + { + "event": { + "type": "http", + "now": 5000, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 5000 + }, + "commands": [ + { + "type": "commit", + "hash": "", + "hashInfo": "", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 5000 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "download_failed" + } + ] + } + ] + }, + { + "name": "forceActivate (the launch waits): activation asked, the commit reports it dropped", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0, + "forceActivate": true + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": true, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": true, + "now": 0, + "activated": false + }, + "commands": [ + { + "type": "finish", + "status": "downloaded", + "reason": "", + "hash": "h2", + "activated": false + } + ] + } + ] + }, + { + "name": "an event the phase does not expect: internal_error, later events ignored", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "installed", + "installed": true, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "internal_error" + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@upToDate", + "at": 1700000000.9 + }, + "commands": [] + } + ] + }, + { + "name": "an event without a round: internal_error", + "steps": [ + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "internal_error" + } + ] + } + ] + }, + { + "name": "a new start replaces a finished round", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "x", + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "invalid_request" + } + ] + }, + { + "event": { + "type": "start", + "config": "", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "skipped", + "reason": "not_configured" + } + ] + } + ] + } + ], + "host": [ + { + "name": "the round's own config, nothing changed", + "hostConfig": "@config", + "roundConfig": "@config", + "storedConfig": "@config", + "roundConfigGeneration": 5, + "configGeneration": 5, + "roundResetGeneration": 3, + "resetGeneration": 3, + "expected": "" + }, + { + "name": "the same config in another serialization is not a change", + "hostConfig": "{\"rn\":\"0.80.0\",\"appKey\":\"key\",\"endpoints\":[\"https://a.example/\",\"https://b.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\"}", + "roundConfig": "@config", + "storedConfig": "@config", + "roundConfigGeneration": 5, + "configGeneration": 5, + "roundResetGeneration": 3, + "resetGeneration": 3, + "expected": "", + "before": { + "harmony": "config_changed (string equality)" + } + }, + { + "name": "the config generation moved", + "hostConfig": "@config", + "roundConfig": "@config", + "storedConfig": "@config", + "roundConfigGeneration": 5, + "configGeneration": 6, + "roundResetGeneration": 3, + "resetGeneration": 3, + "expected": "config_changed" + }, + { + "name": "the round ran with another config", + "hostConfig": "@config", + "roundConfig": "@config.silent", + "storedConfig": "@config", + "roundConfigGeneration": 5, + "configGeneration": 5, + "roundResetGeneration": 3, + "resetGeneration": 3, + "expected": "config_changed" + }, + { + "name": "the stored config changed after the wait", + "hostConfig": "@config", + "roundConfig": "@config", + "storedConfig": "@config.silent", + "roundConfigGeneration": 5, + "configGeneration": 5, + "roundResetGeneration": 3, + "resetGeneration": 3, + "expected": "config_changed" + }, + { + "name": "no config is never equivalent", + "hostConfig": "", + "roundConfig": "", + "storedConfig": "", + "roundConfigGeneration": 5, + "configGeneration": 5, + "roundResetGeneration": 3, + "resetGeneration": 3, + "expected": "config_changed" + }, + { + "name": "a reset since the round started", + "hostConfig": "@config", + "roundConfig": "@config", + "storedConfig": "@config", + "roundConfigGeneration": 5, + "configGeneration": 5, + "roundResetGeneration": 3, + "resetGeneration": 4, + "expected": "reset" + }, + { + "name": "a config change outranks a reset", + "hostConfig": "@config", + "roundConfig": "@config", + "storedConfig": "@config", + "roundConfigGeneration": 5, + "configGeneration": 6, + "roundResetGeneration": 3, + "resetGeneration": 4, + "expected": "config_changed" + } + ], + "safePath": [ + { + "name": "h2", + "expected": true + }, + { + "name": "a.b-c_d", + "expected": true + }, + { + "name": "", + "expected": false + }, + { + "name": ".", + "expected": false + }, + { + "name": "..", + "expected": false + }, + { + "name": "../h", + "expected": false + }, + { + "name": "a/b", + "expected": false + }, + { + "name": "a\\b", + "expected": false + }, + { + "name": "a\u0000b", + "expected": false + }, + { + "name": "...", + "expected": true + } + ] +} diff --git a/cpp/update_flow_core/tests/update_flow_core_test.cpp b/cpp/update_flow_core/tests/update_flow_core_test.cpp index f1481cae..dea448ce 100644 --- a/cpp/update_flow_core/tests/update_flow_core_test.cpp +++ b/cpp/update_flow_core/tests/update_flow_core_test.cpp @@ -14,6 +14,7 @@ #include "../flow_json.h" #include "../native_config.h" #include "../native_round.h" +#include "../round_engine.h" #include "../update_flow_core.h" using flowjson::Parse; @@ -703,6 +704,118 @@ int RunNativeRound(const Value& doc) { return failures; } +// Replaces every "@name" string with fixtures[name]. +Value WithFixtures(const Value& value, const Value& fixtures) { + if (value.IsString() && !value.AsString().empty() && + value.AsString()[0] == '@') { + const Value& fixture = fixtures.Get(value.AsString().substr(1)); + if (fixture.IsUndefined()) { + std::fprintf(stderr, "unknown fixture %s\n", value.AsString().c_str()); + } + return fixture; + } + if (value.IsArray()) { + Value out = Value::Array(); + for (const Value& element : value.elements()) { + out.Push(WithFixtures(element, fixtures)); + } + return out; + } + if (value.IsObject()) { + Value out = Value::Object(); + for (const auto& member : value.members()) { + out.Set(member.first, WithFixtures(member.second, fixtures)); + } + return out; + } + return value; +} + +// The round driver (round_engine.h): every trace in +// tests/round-engine-vectors.json is fed event by event, each step's +// commands compared with the expected ones; then the host cancellation and +// the path component rule. The JSON binding must agree with the Value API. +int RunRoundEngine(const Value& doc) { + int failures = 0; + const Value& fixtures = doc.Get("fixtures"); + const Value& rounds = doc.Get("rounds"); + const Value& host = doc.Get("host"); + const Value& safePath = doc.Get("safePath"); + if (!rounds.IsArray() || rounds.Size() == 0 || !host.IsArray() || + host.Size() == 0 || !safePath.IsArray() || safePath.Size() == 0) { + std::fprintf(stderr, "round engine vectors: missing cases\n"); + return 1; + } + for (const Value& c : rounds.elements()) { + const std::string& name = c.Get("name").AsString(); + Value state = Value::Undefined(); + std::string stateJson; + size_t index = 0; + for (const Value& step : c.Get("steps").elements()) { + const Value event = WithFixtures(step.Get("event"), fixtures); + const Value expected = WithFixtures(step.Get("commands"), fixtures); + const Value result = flowcore::StepRound(state, event); + if (!Value::DeepEquals(result.Get("commands"), expected)) { + std::fprintf(stderr, + "round \"%s\" step %zu MISMATCH\n expected: %s\n" + " actual: %s\n", + name.c_str(), index, Stringify(expected).c_str(), + Stringify(result.Get("commands")).c_str()); + failures++; + } + bool ok = false; + const Value viaJson = + Parse(flowcore::StepRoundJson(stateJson, Stringify(event)), &ok); + if (!ok || Stringify(viaJson) != Stringify(result)) { + std::fprintf(stderr, "round \"%s\" step %zu: StepRoundJson differs\n", + name.c_str(), index); + failures++; + } + state = result.Get("state"); + stateJson = Stringify(state); + index++; + } + if (state.Get("phase").AsString() != "done") { + std::fprintf(stderr, "round \"%s\" did not finish\n", name.c_str()); + failures++; + } + } + for (const Value& raw : host.elements()) { + const Value c = WithFixtures(raw, fixtures); + const std::string actual = flowcore::HostCancellation( + c.Get("hostConfig").AsString(), c.Get("roundConfig").AsString(), + c.Get("storedConfig").AsString(), + c.Get("roundConfigGeneration").AsNumber(), + c.Get("configGeneration").AsNumber(), + c.Get("roundResetGeneration").AsNumber(), + c.Get("resetGeneration").AsNumber()); + if (actual != c.Get("expected").AsString()) { + std::fprintf(stderr, "host \"%s\" MISMATCH: expected \"%s\", got \"%s\"\n", + c.Get("name").AsString().c_str(), + c.Get("expected").AsString().c_str(), actual.c_str()); + failures++; + } + } + for (const Value& c : safePath.elements()) { + if (flowcore::IsSafePathComponent(c.Get("name").AsString()) != + c.Get("expected").AsBool()) { + std::fprintf(stderr, "safePath %s MISMATCH\n", + Stringify(c.Get("name")).c_str()); + failures++; + } + } + // A binding call that cannot parse its input still ends the round. + bool ok = false; + const Value broken = + Parse(flowcore::StepRoundJson("{", "{\"type\":\"env\"}"), &ok); + if (!ok || broken.Get("commands").At(0).Get("reason").AsString() != + "internal_error") { + std::fprintf(stderr, "StepRoundJson: an unparsable state must finish\n"); + failures++; + } + return failures; +} + bool LoadJson(const char* path, Value* doc) { std::ifstream file(path); if (!file) { @@ -722,12 +835,13 @@ bool LoadJson(const char* path, Value* doc) { } // namespace int main(int argc, char* argv[]) { - if (argc < 5) { + if (argc < 6) { std::fprintf(stderr, "usage: %s " " " " " - "\n", + " " + "\n", argv[0]); return 2; } @@ -735,9 +849,11 @@ int main(int argc, char* argv[]) { Value equivalenceDoc; Value nativeConfigDoc; Value nativeRoundDoc; + Value roundEngineDoc; if (!LoadJson(argv[1], &doc) || !LoadJson(argv[2], &equivalenceDoc) || !LoadJson(argv[3], &nativeConfigDoc) || - !LoadJson(argv[4], &nativeRoundDoc)) { + !LoadJson(argv[4], &nativeRoundDoc) || + !LoadJson(argv[5], &roundEngineDoc)) { return 2; } @@ -750,7 +866,8 @@ int main(int argc, char* argv[]) { RunInputCaps() + RunHandleCheckResponse() + RunConfigEquivalence(equivalenceDoc) + RunNativeConfig(nativeConfigDoc) + - RunNativeRound(nativeRoundDoc) + RunTrimAndDeepEquals(); + RunNativeRound(nativeRoundDoc) + + RunRoundEngine(roundEngineDoc) + RunTrimAndDeepEquals(); for (size_t i = 0; i < cases.Size(); i++) { const Value& testCase = cases.At(i); const std::string& fn = testCase.Get("fn").AsString(); @@ -782,12 +899,14 @@ int main(int argc, char* argv[]) { } std::printf( "all %zu flow vectors, %zu config equivalence vectors, %zu+%zu native " - "config vectors, %zu+%zu+%zu native round vectors + parser robustness " - "passed\n", + "config vectors, %zu+%zu+%zu native round vectors, %zu+%zu+%zu round " + "engine vectors + parser robustness passed\n", cases.Size(), equivalenceDoc.Get("cases").Size(), nativeConfigDoc.Get("runnable").Size(), nativeConfigDoc.Get("normalize").Size(), nativeRoundDoc.Get("prepare").Size(), nativeRoundDoc.Get("query").Size(), - nativeRoundDoc.Get("respond").Size()); + nativeRoundDoc.Get("respond").Size(), + roundEngineDoc.Get("rounds").Size(), roundEngineDoc.Get("host").Size(), + roundEngineDoc.Get("safePath").Size()); return 0; } diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index be0a1e98..e8e73558 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -553,3 +553,92 @@ afterDownload)`:本地 silent 策略 或 响应标记 forceBoot 即激活。 改动不触发 .so 重编。发版 10.52.0 后按 2026-08-12 同一实验复测:markSuccess → 落盘标记 → 每次启动 ~230ms 即崩,验证 handler 窗口完成 check+download+ commit、下次启动进修复版,时序记录回 NATIVE_CHECK_FOLLOWUPS.md。 + +## 12. 共享轮次引擎(RoundEngine,2026-10) + +### 12.1 动机 + +§10 的纯决策早已下沉到 `cpp/update_flow_core`(`native_round.h` 的 PrepareRound / +NextQueryStep / HandleRoundResponse,`native_config.h` 的配置门与 C1 等价),由 +`tests/*-vectors.json` 锁定。但把这些步骤串起来的**驱动循环**仍是三份手写:代数采样 +→ 配置门 → 面包屑 → query 循环 → 响应决策 → 已安装判定 → 双预算下载循环 → 原子提交 +→ 结果映射 → 宿主等待后的取消判定。2026-10 的架构审查在这一层逐个人工对照才发现了错误码 +分类、连接超时、prepareBundle 超时、激活前摘要门等漂移;向量只锁住了被调用的纯函数, +锁不住"怎么调用"。 + +### 12.2 形态:sans-IO 的 step 函数 + +`cpp/update_flow_core/round_engine.h`: + +```text +StepRound(state, event) -> { state, commands } +``` + +- **纯函数**:不做 IO、不读时钟、不加锁、不起线程。时间由事件带入(`now`,平台单调时钟 + 毫秒),墙钟只在 HTTP 响应事件里带入(`at`,响应到达时刻,供 §10.3 缓存)。 +- **状态是 JSON 值**(与 NextQueryStep 同一风格):JNI / NAPI 绑定只传文本、不持有句柄, + 没有生命周期与跨线程问题;iOS 直接用 `flowjson::Value`。绑定入口 `StepRoundJson` + 解析失败时直接给出 `finish failed/internal_error`,平台不会卡在无命令的状态。 +- **鸿蒙单线程**:每步都是一次毫秒级的同步调用,绝不阻塞;HTTP、下载等重步骤仍由 ArkTS + 异步执行(下载内部照旧走 napi_async_work)。 + +事件(均带 `now`,可带粘滞标志 `crashHold` / `forceActivate`): + +| 事件 | 字段 | 说明 | +| --- | --- | --- | +| start | config, resetGeneration, configGeneration, sample, deadline | 代数在读配置之前采样;deadline=0 表示无预算 | +| env | env | 回应 collectEnv(bundleHash 等 IO 在面包屑落盘之后才算) | +| http | text?, at | text 缺省 = 请求失败 | +| installed | installed | 回应 checkInstalled | +| download | ok, code?, timedOut? | timedOut:尝试超出了自己的 deadline | +| committed | ok, activated?, error? | ok=false:reset 或配置变更赢了;error:提交本身失败(摘要门拒绝、写入抛错) | + +命令: + +| 命令 | 字段 | 平台职责 | +| --- | --- | --- | +| markIncomplete | — | 落盘 §11.4 面包屑;轮次结束(无论如何结束)由适配器清除 | +| collectEnv | — | 采集本次启动事实,回 env 事件 | +| http | url, post, body?, timeoutMs, callTimeoutMs | 空闲超时与整次超时都已按预算钳制;https→http 重定向拒绝、响应体上限仍在平台 | +| checkInstalled | hash | 完整安装(`.pushy-complete`)是否已在磁盘 | +| download | strategy, url, hash, originHash?, deadline | 下载 + 解压/打补丁;超时由平台取消任务 | +| commit | hash, hashInfo, activate, cache, resetGeneration, configGeneration | 一把锁内复核两个代数,激活前走 CheckActivation 摘要门;hash 为空 = 只提交缓存 | +| finish | status, reason, hash?, activated? | 轮次结束;之后的事件被忽略 | + +阶段:`env → http(循环) → installed → download(循环) → committed → done`。当前阶段不期待的事件 +直接 `finish failed/internal_error`,适配器的编排错误会立刻暴露,而不是静默挂起。 + +### 12.3 引擎负责 / 平台保留 + +引擎负责:配置门(ParseRunnableConfig)、面包屑时机、请求体与 query 遍历、单请求超时 +(空闲 10s、整次 15s,均被预算钳制,预算耗尽不再发请求)、响应决策、hash 路径安全校验、 +已安装短路、双下载预算(diff/pdiff 共享 600s,首个 full 尝试另开 600s,均被预算钳制)、 +镜像重试(IsMirrorRetryableCode:PATCH_FAILED 换策略不换镜像)、超时语义(增量超时换策略, +full 超时整体失败)、激活判定(`activate || crashHold || forceActivate`)与 crash-hold +版本信息标记、提交结果到 status/reason/hash/activated 的映射,以及宿主等待后的取消判定 +`HostCancellation`(配置三方 C1 等价且配置代数不变,否则 config_changed;再看 reset 代数)。 + +平台保留:HTTP 客户端本身、下载与解压管线、持久化与提交的原子性(锁内复核代数、激活摘要门)、 +进程级单轮次(roundStarted CAS)、"JS 已查过"的跳过判定、5s 延迟与零延迟续传、crash-hold 的 +系统钩子与 hold 预算、iOS tvOS purge-restore 的启动阻塞与窗口关闭、Android 的线程与 JNI、 +鸿蒙的异步执行、RN reload。 + +### 12.4 相对原手写循环的有意对齐 + +由 `tests/round-engine-vectors.json` 锁定,接入各端时随之生效: + +- 宿主取消判定:鸿蒙原用字符串全等比较配置,改为 C1 等价(与 Android/iOS 一致)。 +- 提交带配置代数:iOS 早已在锁内复核;Android/鸿蒙接入时补上(原先只在宿主等待后检查)。 +- HTTP 超时:三端统一为空闲 10s + 整次 15s,并按预算钳制(iOS 原先的信号量兜底会比预算多等 5s)。 +- 下载超时:显式 `timedOut`,不再依赖鸿蒙把超时包装成 DOWNLOAD_FAILED 后由下一次循环看 deadline。 +- 只提交缓存的提交失败统一为 `failed/commit_failed`(Android 原为 internal_error)。 +- `finish.activated` 反映实际激活(iOS purge-restore 窗口关闭会丢弃激活),其余平台未上报时取请求值。 +- crash hold 在轮次中途开始也会强制激活(事件携带粘滞标志)。 + +### 12.5 分期 + +1. 引擎 + 轨迹向量 + C++ 测试进 CI(不改平台代码)。 +2. 补 CODE_AUDIT 4.4:鸿蒙 NAPI 回调与 iOS `.mm` 入口的 C++ `catch (...)`——引擎上移扩大了 + C++ 异常穿越 NAPI/ObjC 的面,必须先兜住。 +3. 逐端接入(Android → iOS → 鸿蒙),每端的编排器改为"驱动引擎 + 执行命令"的薄适配器, + 删除被取代的手写循环;Android/鸿蒙在 #669(prepareBundle 超时)合入之后基于最新 master 改。 diff --git a/scripts/test-update-flow-core.sh b/scripts/test-update-flow-core.sh index 917f5883..4e50ba05 100755 --- a/scripts/test-update-flow-core.sh +++ b/scripts/test-update-flow-core.sh @@ -28,7 +28,8 @@ c++ \ "$ROOT_DIR/cpp/update_flow_core/tests/flow_vectors.json" \ "$ROOT_DIR/cpp/update_flow_core/tests/config-equivalence-vectors.json" \ "$ROOT_DIR/cpp/update_flow_core/tests/native-config-vectors.json" \ - "$ROOT_DIR/cpp/update_flow_core/tests/native-round-vectors.json" + "$ROOT_DIR/cpp/update_flow_core/tests/native-round-vectors.json" \ + "$ROOT_DIR/cpp/update_flow_core/tests/round-engine-vectors.json" # Opt-in libFuzzer run of the JSON parser + decision pipeline (raw server # bytes): FUZZ=1 bun run test:flow-core. From 05c8af363bb9464c35de68f1d5ecefaf3c1b0dab Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Thu, 15 Oct 2026 08:29:35 +0800 Subject: [PATCH 2/2] fix(cpp): keep cursors inside a 32-bit size_t and name the generation a commit lost to Index()/Counter() capped at 1e15, which overflows size_t on armeabi-v7a and x86 (undefined behaviour); the full-phase timeout now jumps to the end of the plan instead. Only a committed event reaches OnCommitted, so an event of no known type cannot finish a round as cancelled. A commit that lost to a config change reports cancelled/config_changed (committed.reason), matching HostCancellation. Co-Authored-By: Claude Opus 5.5 --- cpp/update_flow_core/native_round.cpp | 8 +- cpp/update_flow_core/round_engine.cpp | 20 ++- cpp/update_flow_core/round_engine.h | 9 +- .../tests/round-engine-vectors.json | 156 ++++++++++++++++++ docs/NATIVE_CHECKUPDATE_DESIGN.md | 2 +- 5 files changed, 181 insertions(+), 14 deletions(-) diff --git a/cpp/update_flow_core/native_round.cpp b/cpp/update_flow_core/native_round.cpp index b8597f31..96594aca 100644 --- a/cpp/update_flow_core/native_round.cpp +++ b/cpp/update_flow_core/native_round.cpp @@ -67,14 +67,16 @@ Value StringOr(const Value& value, const char* fallback) { return value.IsString() ? value : Value::String(fallback); } -// A non-negative integral state counter; anything else reads as 0. +// A non-negative integral state counter; anything else reads as 0. Capped +// well inside a 32-bit size_t (armeabi-v7a, x86): converting a double beyond +// the target's range is undefined. size_t Counter(const Value& value) { const double number = value.AsNumber(); if (!value.IsNumber() || !std::isfinite(number) || number < 0) { return 0; } - return number >= 1e15 ? static_cast(1e15) - : static_cast(number); + return number >= 1e9 ? static_cast(1e9) + : static_cast(number); } // { packageVersion, currentVersion, uuid, rolledBackVersion } — what diff --git a/cpp/update_flow_core/round_engine.cpp b/cpp/update_flow_core/round_engine.cpp index 90aa197d..1103e294 100644 --- a/cpp/update_flow_core/round_engine.cpp +++ b/cpp/update_flow_core/round_engine.cpp @@ -40,11 +40,13 @@ double Number(const Value& value) { return value.IsNumber() && std::isfinite(number) ? number : 0; } -// A non-negative integral cursor; anything else reads as 0. +// A non-negative integral cursor; anything else reads as 0. Capped well +// inside a 32-bit size_t (armeabi-v7a, x86): converting a double beyond the +// target's range is undefined. size_t Index(const Value& value) { const double number = Number(value); - return number < 0 ? 0 : number >= 1e15 ? static_cast(1e15) - : static_cast(number); + return number < 0 ? 0 : number >= 1e9 ? static_cast(1e9) + : static_cast(number); } Value Command(const char* type) { @@ -93,8 +95,10 @@ class Step { OnInstalled(); } else if (type == kAwaitDownload) { OnDownload(); - } else { + } else if (type == kAwaitCommit) { OnCommitted(); + } else { + Finish("failed", "internal_error"); } return Result(); } @@ -315,7 +319,8 @@ class Step { !IsMirrorRetryableCode(event_.Get("code").AsString()); if (event_.Get("timedOut").Truthy() && !IsIncremental(strategy)) { // The full phase timed out: no budget is left for anything. - cursor.Set("attempt", Value::Number(1e15)); + cursor.Set("attempt", Value::Number(static_cast( + state_.Get("decision").Get("attempts").Size()))); } else if (nextStrategy) { cursor.Set("attempt", Value::Number(static_cast(attempt + 1))); cursor.Set("url", Value::Number(0)); @@ -375,7 +380,10 @@ class Step { if (event_.Get("error").Truthy()) { Finish("failed", "commit_failed"); } else if (!event_.Get("ok").Truthy()) { - Finish("cancelled", "reset"); + // The generation that moved: a config change, or (by default) a reset. + Finish("cancelled", event_.Get("reason").AsString() == "config_changed" + ? "config_changed" + : "reset"); } else if (result.Get("hash").IsString()) { const Value& activated = event_.Get("activated"); Finish(result.Get("status").AsString(), result.Get("reason").AsString(), diff --git a/cpp/update_flow_core/round_engine.h b/cpp/update_flow_core/round_engine.h index 0b724e6d..e412250c 100644 --- a/cpp/update_flow_core/round_engine.h +++ b/cpp/update_flow_core/round_engine.h @@ -50,8 +50,9 @@ constexpr double kQueryCallTimeoutMs = 15 * 1000; // installed installed: a complete install of the hash is already on disk // download ok; code: the failure's error code; timedOut: the attempt // outlived its deadline -// committed ok: the commit landed (false: a reset or a config change -// since the round started won); activated: what it really +// committed ok: the commit landed; when false, reason names the +// generation that moved since the round started ("reset", the +// default, or "config_changed"); activated: what it really // activated (absent = as asked); error: the commit itself failed // (the activation gate rejected the version, a write threw) // @@ -73,8 +74,8 @@ constexpr double kQueryCallTimeoutMs = 15 * 1000; // downloaded but not activated version is the one a later // crash hold may activate. // -// An event the current phase does not expect finishes the round as -// failed/internal_error. +// An event the current phase does not expect (or of no known type) finishes +// the round as failed/internal_error. flowjson::Value StepRound(const flowjson::Value& state, const flowjson::Value& event); diff --git a/cpp/update_flow_core/tests/round-engine-vectors.json b/cpp/update_flow_core/tests/round-engine-vectors.json index f7a37372..c06a6a84 100644 --- a/cpp/update_flow_core/tests/round-engine-vectors.json +++ b/cpp/update_flow_core/tests/round-engine-vectors.json @@ -1955,6 +1955,111 @@ } ] }, + { + "name": "a config change during the download wins the commit", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "type": "env", + "env": "@env", + "now": 0 + }, + "commands": [ + { + "type": "http", + "url": "https://a.example/checkUpdate/key", + "post": true, + "body": "@body", + "timeoutMs": 10000, + "callTimeoutMs": 15000 + } + ] + }, + { + "event": { + "type": "http", + "now": 0, + "text": "@v2", + "at": 1700000000.9 + }, + "commands": [ + { + "type": "checkInstalled", + "hash": "h2" + } + ] + }, + { + "event": { + "type": "installed", + "installed": false, + "now": 0 + }, + "commands": [ + { + "type": "download", + "strategy": "diff", + "url": "https://cdn1.example/d.hdiff", + "hash": "h2", + "originHash": "cur", + "deadline": 600000 + } + ] + }, + { + "event": { + "type": "download", + "ok": true, + "now": 0 + }, + "commands": [ + { + "type": "commit", + "hash": "h2", + "hashInfo": "{\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"m\"}", + "activate": false, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"m\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"https://cdn1.example\\\",\\\"https://cdn2.example\\\"]}\",\"request\":\"{\\\"packageVersion\\\":\\\"1.0.0\\\",\\\"hash\\\":\\\"cur\\\",\\\"buildTime\\\":\\\"1700000000\\\",\\\"cInfo\\\":{\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"os\\\":\\\"android 34\\\",\\\"uuid\\\":\\\"u-1\\\"},\\\"diffV\\\":2,\\\"bundleHash\\\":\\\"bh\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\",\\\"https://b.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}", + "resetGeneration": 3, + "configGeneration": 5 + } + ] + }, + { + "event": { + "type": "committed", + "ok": false, + "now": 0, + "reason": "config_changed" + }, + "commands": [ + { + "type": "finish", + "status": "cancelled", + "reason": "config_changed" + } + ] + } + ] + }, { "name": "the commit itself fails: commit_failed", "steps": [ @@ -2801,6 +2906,57 @@ } ] }, + { + "name": "an event without a type: internal_error", + "steps": [ + { + "event": { + "type": "start", + "config": "@config", + "resetGeneration": 3, + "configGeneration": 5, + "sample": 0, + "deadline": 0, + "now": 0 + }, + "commands": [ + { + "type": "markIncomplete" + }, + { + "type": "collectEnv" + } + ] + }, + { + "event": { + "now": 0 + }, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "internal_error" + } + ] + } + ] + }, + { + "name": "an empty event without a round: internal_error", + "steps": [ + { + "event": {}, + "commands": [ + { + "type": "finish", + "status": "failed", + "reason": "internal_error" + } + ] + } + ] + }, { "name": "a new start replaces a finished round", "steps": [ diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index e8e73558..f6d2010b 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -591,7 +591,7 @@ StepRound(state, event) -> { state, commands } | http | text?, at | text 缺省 = 请求失败 | | installed | installed | 回应 checkInstalled | | download | ok, code?, timedOut? | timedOut:尝试超出了自己的 deadline | -| committed | ok, activated?, error? | ok=false:reset 或配置变更赢了;error:提交本身失败(摘要门拒绝、写入抛错) | +| committed | ok, reason?, activated?, error? | ok=false:reset 或配置变更赢了,reason 指明是哪个代数(默认 reset,或 config_changed);error:提交本身失败(摘要门拒绝、写入抛错) | 命令: