06 - atomic update

This commit is contained in:
deeaitch committed 2026-10-20 12:27:04 -04:00
1 parent 6d63457737
commit 2bdacaf37a
21 files changed
+1302 -365

No files matched your search

+8 -7
View File
@@ -4,26 +4,27 @@
#include <iostream>
#include <stdexcept>
int main(int argc, char* argv[]) {
int main(int argc, char *argv[]) {
const std::string selected = argc > 1 ? argv[1] : "all";
if (argc > 2 || selected == "--help" || selected == "-h" ||
(selected != "all" && std::find(ota::scenarios().begin(), ota::scenarios().end(),
selected) == ota::scenarios().end())) {
std::ostream& output = (argc == 2 && (selected == "--help" || selected == "-h"))
? std::cout : std::cerr;
selected) == ota::scenarios().end())) {
std::ostream &output = (argc == 2 && (selected == "--help" || selected == "-h"))
? std::cout
: std::cerr;
output << "Usage: ab-simulate [all";
for (const auto& scenario : ota::scenarios()) output << '|' << scenario;
for (const auto &scenario: ota::scenarios()) output << '|' << scenario;
output << "]\n";
return &output == &std::cout ? 0 : 2;
}
try {
for (const auto& scenario : ota::scenarios()) {
for (const auto &scenario: ota::scenarios()) {
if (selected != "all" && selected != scenario) continue;
std::cout << "\n=== " << scenario << " ===\n";
ota::TemporaryDirectory directory;
ota::run_scenario(scenario, directory.path(), &std::cout);
}
} catch (const std::exception& error) {
} catch (const std::exception &error) {
std::cerr << "Simulation failed: " << error.what() << '\n';
return 1;
}
+205 -208
View File
@@ -8,226 +8,223 @@
#include <utility>
namespace ota {
namespace {
constexpr const char* phase_names[] = {
"A confirmed", "Prepare B", "B prepared and verified", "B trial pending",
"B trial running", "B confirmed", "Recovery to A pending",
"A recovery running", "A recovery checked", "Separate recovery required"
};
namespace {
constexpr const char *phase_names[] = {
"A confirmed", "Prepare B", "B prepared and verified", "B trial pending",
"B trial running", "B confirmed", "Recovery to A pending",
"A recovery running", "A recovery checked", "Separate recovery required"};
Phase read_phase(const std::string& value) {
for (int i = 0; i < 10; ++i) {
if (value == phase_names[i]) return static_cast<Phase>(i);
}
throw std::runtime_error("Invalid saved phase");
}
Phase read_phase(const std::string &value) {
for (int i = 0; i < 10; ++i) {
if (value == phase_names[i]) return static_cast<Phase>(i);
}
throw std::runtime_error("Invalid saved phase");
}
Slot read_slot(const std::string& value) {
if (value == "A") return Slot::A;
if (value == "B") return Slot::B;
throw std::runtime_error("Invalid saved slot");
}
Slot read_slot(const std::string &value) {
if (value == "A") return Slot::A;
if (value == "B") return Slot::B;
throw std::runtime_error("Invalid saved slot");
}
void require(bool condition, const char* message) {
if (!condition) throw std::logic_error(message);
}
} // namespace
void require(bool condition, const char *message) {
if (!condition) throw std::logic_error(message);
}
}// namespace
const char* name(Phase phase) { return phase_names[static_cast<int>(phase)]; }
const char* name(Slot slot) { return slot == Slot::A ? "A" : "B"; }
const char *name(Phase phase) { return phase_names[static_cast<int>(phase)]; }
const char *name(Slot slot) { return slot == Slot::A ? "A" : "B"; }
Device::Device(const std::filesystem::path& directory)
: path_(directory / "boot-state.txt") {
if (!std::filesystem::exists(path_)) {
save();
return;
}
std::ifstream input(path_);
std::string header, phase, normal, trial;
int a_ready = 0, b_ready = 0;
if (!(input >> header >> std::quoted(phase) >> normal >> trial
>> state_.attempts_left >> a_ready >> b_ready
>> std::quoted(state_.outcome)) || header != "OTA_AB_STATE_V1") {
throw std::runtime_error("Unreadable saved boot state");
}
input >> std::ws;
if (!input.eof() || state_.attempts_left < 0 ||
(a_ready != 0 && a_ready != 1) || (b_ready != 0 && b_ready != 1)) {
throw std::runtime_error("Invalid saved boot state");
}
state_.phase = read_phase(phase);
state_.normal = read_slot(normal);
if (trial != "none") state_.trial = read_slot(trial);
state_.a_ready = a_ready != 0;
state_.b_ready = b_ready != 0;
}
void Device::save() const {
auto temporary = path_;
temporary += ".tmp";
std::ofstream output(temporary, std::ios::trunc);
output << "OTA_AB_STATE_V1\n" << std::quoted(name(state_.phase)) << '\n'
<< name(state_.normal) << '\n'
<< (state_.trial ? name(*state_.trial) : "none") << '\n'
<< state_.attempts_left << '\n'
<< state_.a_ready << ' ' << state_.b_ready << '\n'
<< std::quoted(state_.outcome) << '\n';
output.close();
if (!output) throw std::runtime_error("Cannot save boot state");
// Teaching stand-in: rename alone does not guarantee power-loss durability.
std::filesystem::rename(temporary, path_);
}
void Device::prepare(bool complete) {
require(state_.phase == Phase::AConfirmed || state_.phase == Phase::PreparingB,
"Cannot prepare B in this phase");
state_.b_ready = false;
state_.phase = Phase::PreparingB;
save();
if (complete) {
// Represents complete installation AND artifact verification.
state_.b_ready = true;
state_.phase = Phase::BPrepared;
save();
}
}
void Device::request_trial(int attempts) {
require(state_.phase == Phase::BPrepared && state_.b_ready,
"B must be prepared and verified before trial");
require(attempts > 0, "The trial needs at least one attempt");
state_.phase = Phase::TrialPending;
state_.trial = Slot::B;
state_.attempts_left = attempts;
save();
}
Slot Device::boot() {
require(state_.phase != Phase::SeparateRecoveryRequired,
"Separate recovery is required; do not alternate failed systems");
if (state_.trial) {
if (state_.attempts_left > 0) {
--state_.attempts_left;
state_.phase = Phase::TrialRunning;
// Persist the consumed attempt BEFORE handing control to B.
Device::Device(const std::filesystem::path &directory)
: path_(directory / "boot-state.txt") {
if (!std::filesystem::exists(path_)) {
save();
running_ = *state_.trial;
return *running_;
}
state_.phase = Phase::RecoveryPending;
state_.trial.reset();
save();
}
if (state_.phase == Phase::RecoveryPending) {
state_.phase = Phase::RecoveryRunning;
save();
}
running_ = state_.normal;
return *running_;
}
void Device::check(bool healthy) {
require(running_.has_value(), "Boot before checking the running system");
require(state_.phase == Phase::TrialRunning || state_.phase == Phase::RecoveryRunning,
"Checks require a running trial or recovery");
if (state_.phase == Phase::TrialRunning) {
state_.trial.reset();
state_.attempts_left = 0;
if (healthy) {
state_.phase = Phase::BConfirmed;
state_.normal = Slot::B;
state_.outcome = "B confirmed";
} else {
state_.phase = Phase::RecoveryPending;
}
} else if (healthy) {
state_.phase = Phase::RecoveryChecked;
state_.outcome = "Recovered to A";
} else {
state_.phase = Phase::SeparateRecoveryRequired;
state_.outcome = "Recovery A failed";
}
save();
}
TemporaryDirectory::TemporaryDirectory() {
std::random_device random;
for (int attempt = 0; attempt < 100; ++attempt) {
auto candidate = std::filesystem::temp_directory_path() /
("ota-ab-simulation-" + std::to_string(random()) + "-" + std::to_string(random()));
if (std::filesystem::create_directory(candidate)) {
path_ = std::move(candidate);
return;
}
std::ifstream input(path_);
std::string header, phase, normal, trial;
int a_ready = 0, b_ready = 0;
if (!(input >> header >> std::quoted(phase) >> normal >> trial >> state_.attempts_left >> a_ready >> b_ready >> std::quoted(state_.outcome)) || header != "OTA_AB_STATE_V1") {
throw std::runtime_error("Unreadable saved boot state");
}
input >> std::ws;
if (!input.eof() || state_.attempts_left < 0 ||
(a_ready != 0 && a_ready != 1) || (b_ready != 0 && b_ready != 1)) {
throw std::runtime_error("Invalid saved boot state");
}
state_.phase = read_phase(phase);
state_.normal = read_slot(normal);
if (trial != "none") state_.trial = read_slot(trial);
state_.a_ready = a_ready != 0;
state_.b_ready = b_ready != 0;
}
throw std::runtime_error("Cannot create a unique temporary directory");
}
TemporaryDirectory::~TemporaryDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
const std::vector<std::string>& scenarios() {
static const std::vector<std::string> names = {
"success", "health-failure", "attempts-exhausted", "restart-during-trial",
"interrupted-preparation", "recovery-failure"
};
return names;
}
Device run_scenario(const std::string& scenario, const std::filesystem::path& directory,
std::ostream* output) {
if (std::find(scenarios().begin(), scenarios().end(), scenario) == scenarios().end()) {
throw std::invalid_argument("Unknown scenario: " + scenario);
void Device::save() const {
auto temporary = path_;
temporary += ".tmp";
std::ofstream output(temporary, std::ios::trunc);
output << "OTA_AB_STATE_V1\n"
<< std::quoted(name(state_.phase)) << '\n'
<< name(state_.normal) << '\n'
<< (state_.trial ? name(*state_.trial) : "none") << '\n'
<< state_.attempts_left << '\n'
<< state_.a_ready << ' ' << state_.b_ready << '\n'
<< std::quoted(state_.outcome) << '\n';
output.close();
if (!output) throw std::runtime_error("Cannot save boot state");
// Teaching stand-in: rename alone does not guarantee power-loss durability.
std::filesystem::rename(temporary, path_);
}
Device device(directory);
auto show = [&](const std::string& event) {
if (!output) return;
const auto& state = device.state();
*output << event << ": " << name(state.phase) << "; normal=" << name(state.normal)
<< "; attempts_left=" << state.attempts_left << "; running="
<< (device.running() ? name(*device.running()) : "none") << '\n';
};
auto reset = [&] {
device = Device(directory);
const auto selected = device.boot();
show(std::string("Reset -> ") + name(selected));
};
auto message = [&](const char* text) { if (output) *output << text << '\n'; };
show("Initial state");
if (scenario == "interrupted-preparation") {
device.prepare(false);
show("Interrupted installation: B remains ineligible");
void Device::prepare(bool complete) {
require(state_.phase == Phase::AConfirmed || state_.phase == Phase::PreparingB,
"Cannot prepare B in this phase");
state_.b_ready = false;
state_.phase = Phase::PreparingB;
save();
if (complete) {
// Represents complete installation AND artifact verification.
state_.b_ready = true;
state_.phase = Phase::BPrepared;
save();
}
}
void Device::request_trial(int attempts) {
require(state_.phase == Phase::BPrepared && state_.b_ready,
"B must be prepared and verified before trial");
require(attempts > 0, "The trial needs at least one attempt");
state_.phase = Phase::TrialPending;
state_.trial = Slot::B;
state_.attempts_left = attempts;
save();
}
Slot Device::boot() {
require(state_.phase != Phase::SeparateRecoveryRequired,
"Separate recovery is required; do not alternate failed systems");
if (state_.trial) {
if (state_.attempts_left > 0) {
--state_.attempts_left;
state_.phase = Phase::TrialRunning;
// Persist the consumed attempt BEFORE handing control to B.
save();
running_ = *state_.trial;
return *running_;
}
state_.phase = Phase::RecoveryPending;
state_.trial.reset();
save();
}
if (state_.phase == Phase::RecoveryPending) {
state_.phase = Phase::RecoveryRunning;
save();
}
running_ = state_.normal;
return *running_;
}
void Device::check(bool healthy) {
require(running_.has_value(), "Boot before checking the running system");
require(state_.phase == Phase::TrialRunning || state_.phase == Phase::RecoveryRunning,
"Checks require a running trial or recovery");
if (state_.phase == Phase::TrialRunning) {
state_.trial.reset();
state_.attempts_left = 0;
if (healthy) {
state_.phase = Phase::BConfirmed;
state_.normal = Slot::B;
state_.outcome = "B confirmed";
} else {
state_.phase = Phase::RecoveryPending;
}
} else if (healthy) {
state_.phase = Phase::RecoveryChecked;
state_.outcome = "Recovered to A";
} else {
state_.phase = Phase::SeparateRecoveryRequired;
state_.outcome = "Recovery A failed";
}
save();
}
TemporaryDirectory::TemporaryDirectory() {
std::random_device random;
for (int attempt = 0; attempt < 100; ++attempt) {
auto candidate = std::filesystem::temp_directory_path() /
("ota-ab-simulation-" + std::to_string(random()) + "-" + std::to_string(random()));
if (std::filesystem::create_directory(candidate)) {
path_ = std::move(candidate);
return;
}
}
throw std::runtime_error("Cannot create a unique temporary directory");
}
TemporaryDirectory::~TemporaryDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
const std::vector<std::string> &scenarios() {
static const std::vector<std::string> names = {
"success", "health-failure", "attempts-exhausted", "restart-during-trial",
"interrupted-preparation", "recovery-failure"};
return names;
}
Device run_scenario(const std::string &scenario, const std::filesystem::path &directory,
std::ostream *output) {
if (std::find(scenarios().begin(), scenarios().end(), scenario) == scenarios().end()) {
throw std::invalid_argument("Unknown scenario: " + scenario);
}
Device device(directory);
auto show = [&](const std::string &event) {
if (!output) return;
const auto &state = device.state();
*output << event << ": " << name(state.phase) << "; normal=" << name(state.normal)
<< "; attempts_left=" << state.attempts_left << "; running="
<< (device.running() ? name(*device.running()) : "none") << '\n';
};
auto reset = [&] {
device = Device(directory);
const auto selected = device.boot();
show(std::string("Reset -> ") + name(selected));
};
auto message = [&](const char *text) { if (output) *output << text << '\n'; };
show("Initial state");
if (scenario == "interrupted-preparation") {
device.prepare(false);
show("Interrupted installation: B remains ineligible");
reset();
return device;
}
device.prepare();
show("Installation and verification complete");
device.request_trial(2);
show("Trial requested");
reset();
if (scenario == "attempts-exhausted" || scenario == "restart-during-trial") {
message("No confirmation: simulate a reset caused by power loss or watchdog");
reset();
}
if (scenario == "attempts-exhausted") {
message("B still cannot reach health checks; simulate another reset");
reset();
device.check(true);
show("Recovery checks pass");
} else if (scenario == "health-failure" || scenario == "recovery-failure") {
device.check(false);
show("B health checks fail");
reset();
device.check(scenario != "recovery-failure");
show("Recovery checks complete");
} else {
device.check(true);
show("B health checks pass; confirmation saved");
reset();
}
return device;
}
device.prepare();
show("Installation and verification complete");
device.request_trial(2);
show("Trial requested");
reset();
if (scenario == "attempts-exhausted" || scenario == "restart-during-trial") {
message("No confirmation: simulate a reset caused by power loss or watchdog");
reset();
}
if (scenario == "attempts-exhausted") {
message("B still cannot reach health checks; simulate another reset");
reset();
device.check(true);
show("Recovery checks pass");
} else if (scenario == "health-failure" || scenario == "recovery-failure") {
device.check(false);
show("B health checks fail");
reset();
device.check(scenario != "recovery-failure");
show("Recovery checks complete");
} else {
device.check(true);
show("B health checks pass; confirmation saved");
reset();
}
return device;
}
} // namespace ota
}// namespace ota
@@ -8,61 +8,69 @@
namespace ota {
enum class Phase {
AConfirmed, PreparingB, BPrepared, TrialPending, TrialRunning,
BConfirmed, RecoveryPending, RecoveryRunning, RecoveryChecked,
SeparateRecoveryRequired
};
enum class Phase {
AConfirmed,
PreparingB,
BPrepared,
TrialPending,
TrialRunning,
BConfirmed,
RecoveryPending,
RecoveryRunning,
RecoveryChecked,
SeparateRecoveryRequired
};
enum class Slot { A, B };
enum class Slot { A,
B };
const char* name(Phase phase);
const char* name(Slot slot);
const char *name(Phase phase);
const char *name(Slot slot);
struct State {
Phase phase = Phase::AConfirmed;
Slot normal = Slot::A;
std::optional<Slot> trial;
int attempts_left = 0;
bool a_ready = true;
bool b_ready = false;
std::string outcome;
};
struct State {
Phase phase = Phase::AConfirmed;
Slot normal = Slot::A;
std::optional<Slot> trial;
int attempts_left = 0;
bool a_ready = true;
bool b_ready = false;
std::string outcome;
};
class Device {
public:
explicit Device(const std::filesystem::path& directory);
const State& state() const { return state_; }
std::optional<Slot> running() const { return running_; }
void prepare(bool complete = true);
void request_trial(int attempts = 2);
Slot boot();
void check(bool healthy);
class Device {
public:
explicit Device(const std::filesystem::path &directory);
const State &state() const { return state_; }
std::optional<Slot> running() const { return running_; }
void prepare(bool complete = true);
void request_trial(int attempts = 2);
Slot boot();
void check(bool healthy);
private:
void save() const;
State state_;
std::filesystem::path path_;
// Volatile: reconstructing a Device requires another boot before checks.
std::optional<Slot> running_;
};
private:
void save() const;
State state_;
std::filesystem::path path_;
// Volatile: reconstructing a Device requires another boot before checks.
std::optional<Slot> running_;
};
// Own only a freshly created directory and clean it up at scope exit.
class TemporaryDirectory {
public:
TemporaryDirectory();
~TemporaryDirectory();
TemporaryDirectory(const TemporaryDirectory&) = delete;
TemporaryDirectory& operator=(const TemporaryDirectory&) = delete;
const std::filesystem::path& path() const { return path_; }
// Own only a freshly created directory and clean it up at scope exit.
class TemporaryDirectory {
public:
TemporaryDirectory();
~TemporaryDirectory();
TemporaryDirectory(const TemporaryDirectory &) = delete;
TemporaryDirectory &operator=(const TemporaryDirectory &) = delete;
const std::filesystem::path &path() const { return path_; }
private:
std::filesystem::path path_;
};
private:
std::filesystem::path path_;
};
const std::vector<std::string>& scenarios();
Device run_scenario(const std::string& scenario,
const std::filesystem::path& directory,
std::ostream* output = nullptr);
const std::vector<std::string> &scenarios();
Device run_scenario(const std::string &scenario,
const std::filesystem::path &directory,
std::ostream *output = nullptr);
} // namespace ota
}// namespace ota
+92 -93
View File
@@ -6,108 +6,107 @@
#include <string>
namespace {
void expect(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
template <class Operation>
void expect_rejected(Operation operation) {
try {
operation();
} catch (const std::logic_error&) {
return;
void expect(bool condition, const char *message) {
if (!condition) throw std::runtime_error(message);
}
throw std::runtime_error("Invalid operation was accepted");
}
void scenario_outcomes() {
struct Expected {
const char* scenario;
ota::Phase phase;
ota::Slot normal;
const char* outcome;
};
const Expected expected[] = {
{"success", ota::Phase::BConfirmed, ota::Slot::B, "B confirmed"},
{"health-failure", ota::Phase::RecoveryChecked, ota::Slot::A, "Recovered to A"},
{"attempts-exhausted", ota::Phase::RecoveryChecked, ota::Slot::A, "Recovered to A"},
{"restart-during-trial", ota::Phase::BConfirmed, ota::Slot::B, "B confirmed"},
{"interrupted-preparation", ota::Phase::PreparingB, ota::Slot::A, ""},
{"recovery-failure", ota::Phase::SeparateRecoveryRequired, ota::Slot::A,
"Recovery A failed"}
};
for (const auto& result : expected) {
ota::TemporaryDirectory directory;
auto device = ota::run_scenario(result.scenario, directory.path());
const ota::Device restored(directory.path());
const auto& state = restored.state();
expect(state.phase == result.phase, "Unexpected saved phase");
expect(state.normal == result.normal, "Unexpected normal slot");
expect(state.outcome == result.outcome, "Unexpected saved outcome");
expect(state.a_ready, "The retained slot was modified");
if (state.phase == ota::Phase::SeparateRecoveryRequired) {
expect_rejected([&] { device.boot(); });
template<class Operation>
void expect_rejected(Operation operation) {
try {
operation();
} catch (const std::logic_error &) {
return;
}
throw std::runtime_error("Invalid operation was accepted");
}
void scenario_outcomes() {
struct Expected {
const char *scenario;
ota::Phase phase;
ota::Slot normal;
const char *outcome;
};
const Expected expected[] = {
{"success", ota::Phase::BConfirmed, ota::Slot::B, "B confirmed"},
{"health-failure", ota::Phase::RecoveryChecked, ota::Slot::A, "Recovered to A"},
{"attempts-exhausted", ota::Phase::RecoveryChecked, ota::Slot::A, "Recovered to A"},
{"restart-during-trial", ota::Phase::BConfirmed, ota::Slot::B, "B confirmed"},
{"interrupted-preparation", ota::Phase::PreparingB, ota::Slot::A, ""},
{"recovery-failure", ota::Phase::SeparateRecoveryRequired, ota::Slot::A,
"Recovery A failed"}};
for (const auto &result: expected) {
ota::TemporaryDirectory directory;
auto device = ota::run_scenario(result.scenario, directory.path());
const ota::Device restored(directory.path());
const auto &state = restored.state();
expect(state.phase == result.phase, "Unexpected saved phase");
expect(state.normal == result.normal, "Unexpected normal slot");
expect(state.outcome == result.outcome, "Unexpected saved outcome");
expect(state.a_ready, "The retained slot was modified");
if (state.phase == ota::Phase::SeparateRecoveryRequired) {
expect_rejected([&] { device.boot(); });
}
}
}
}
void saved_attempts() {
ota::TemporaryDirectory directory;
ota::Device device(directory.path());
device.prepare();
device.request_trial(2);
for (int left : {1, 0}) {
void saved_attempts() {
ota::TemporaryDirectory directory;
ota::Device device(directory.path());
device.prepare();
device.request_trial(2);
for (int left: {1, 0}) {
device = ota::Device(directory.path());
expect(device.boot() == ota::Slot::B, "Expected trial boot of B");
const ota::Device restored(directory.path());
expect(restored.state().attempts_left == left, "Attempt was not saved before boot");
expect(restored.state().normal == ota::Slot::A, "B was confirmed prematurely");
}
device = ota::Device(directory.path());
expect(device.boot() == ota::Slot::B, "Expected trial boot of B");
const ota::Device restored(directory.path());
expect(restored.state().attempts_left == left, "Attempt was not saved before boot");
expect(restored.state().normal == ota::Slot::A, "B was confirmed prematurely");
expect(device.boot() == ota::Slot::A, "Exhausted trial did not return to A");
expect(device.state().phase == ota::Phase::RecoveryRunning, "Recovery checks skipped");
}
device = ota::Device(directory.path());
expect(device.boot() == ota::Slot::A, "Exhausted trial did not return to A");
expect(device.state().phase == ota::Phase::RecoveryRunning, "Recovery checks skipped");
}
void incomplete_slot() {
ota::TemporaryDirectory directory;
ota::Device device(directory.path());
device.prepare(false);
device = ota::Device(directory.path());
expect(!device.state().b_ready, "Incomplete B was marked ready");
expect_rejected([&] { device.request_trial(); });
expect(device.boot() == ota::Slot::A, "Incomplete B was selected");
}
void confirmation_requires_boot() {
ota::TemporaryDirectory directory;
ota::Device device(directory.path());
device.prepare();
expect_rejected([&] { device.request_trial(0); });
expect_rejected([&] { device.request_trial(-1); });
device.request_trial();
expect_rejected([&] { device.check(true); });
device.boot();
ota::Device restored(directory.path());
expect(!restored.running(), "Running slot survived reconstruction");
expect_rejected([&] { restored.check(true); });
device.check(true);
expect_rejected([&] { device.check(false); });
}
void unreadable_state() {
ota::TemporaryDirectory directory;
{
std::ofstream output(directory.path() / "boot-state.txt");
output << "incomplete record\n";
void incomplete_slot() {
ota::TemporaryDirectory directory;
ota::Device device(directory.path());
device.prepare(false);
device = ota::Device(directory.path());
expect(!device.state().b_ready, "Incomplete B was marked ready");
expect_rejected([&] { device.request_trial(); });
expect(device.boot() == ota::Slot::A, "Incomplete B was selected");
}
try {
const ota::Device device(directory.path());
} catch (const std::runtime_error&) {
return;
void confirmation_requires_boot() {
ota::TemporaryDirectory directory;
ota::Device device(directory.path());
device.prepare();
expect_rejected([&] { device.request_trial(0); });
expect_rejected([&] { device.request_trial(-1); });
device.request_trial();
expect_rejected([&] { device.check(true); });
device.boot();
ota::Device restored(directory.path());
expect(!restored.running(), "Running slot survived reconstruction");
expect_rejected([&] { restored.check(true); });
device.check(true);
expect_rejected([&] { device.check(false); });
}
throw std::runtime_error("Unreadable state was accepted");
}
} // namespace
void unreadable_state() {
ota::TemporaryDirectory directory;
{
std::ofstream output(directory.path() / "boot-state.txt");
output << "incomplete record\n";
}
try {
const ota::Device device(directory.path());
} catch (const std::runtime_error &) {
return;
}
throw std::runtime_error("Unreadable state was accepted");
}
}// namespace
int main() {
try {
@@ -117,7 +116,7 @@ int main() {
confirmation_requires_boot();
unreadable_state();
std::cout << "All recovery invariant checks passed\n";
} catch (const std::exception& error) {
} catch (const std::exception &error) {
std::cerr << "Test failed: " << error.what() << '\n';
return 1;
}
@@ -0,0 +1,23 @@
cmake_minimum_required(VERSION 3.20)
project(interrupted_installation LANGUAGES CXX)
set(CMAKE_CXX_EXTENSIONS OFF)
add_library(installation-model simulation.cpp)
target_compile_features(installation-model PUBLIC cxx_std_17)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(installation-model PRIVATE -Wall -Wextra -Wpedantic)
endif()
add_executable(installation-simulate main.cpp)
target_link_libraries(installation-simulate PRIVATE installation-model)
include(CTest)
if(BUILD_TESTING)
add_executable(installation-tests tests.cpp)
target_link_libraries(installation-tests PRIVATE installation-model)
add_test(NAME installation-invariants COMMAND installation-tests)
add_test(NAME all-scenarios COMMAND installation-simulate all)
add_test(NAME cli-help COMMAND installation-simulate --help)
add_test(NAME cli-invalid COMMAND installation-simulate invalid)
set_tests_properties(cli-invalid PROPERTIES WILL_FAIL TRUE)
endif()
@@ -0,0 +1,49 @@
#include "simulation.hpp"
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>
namespace model = ota::installation;
int main(int argc, char **argv) {
if (argc == 2 && std::string(argv[1]) == "--help") {
std::cout << "Usage: installation-simulate [all|--help]\n";
return 0;
}
if (argc > 2 || (argc == 2 && std::string(argv[1]) != "all")) {
std::cerr << "Usage: installation-simulate [all|--help]\n";
return 1;
}
try {
for (int cut = 0; cut <= model::installation_steps; ++cut) {
model::Storage storage;
{
model::Installer installer(storage);
for (int step = 0; step < cut; ++step) model::perform_step(installer, step);
}
model::Installer restarted(storage);
const auto action = restarted.recover();
std::cout << "Reset after " << cut << " steps: boot=" << model::boot_choice(storage.boot)
<< "; " << model::name(action) << '\n';
}
for (const auto result: {model::WriteResult::OldRecord, model::WriteResult::NewRecord, model::WriteResult::InvalidRecord}) {
model::Storage storage;
{
model::Installer installer(storage);
for (int step = 0; step < 5; ++step) model::perform_step(installer, step);
try {
installer.request_trial(result);
} catch (const std::runtime_error &error) {
std::cout << error.what() << '\n';
}
}
model::Installer restarted(storage);
std::cout << "Boot-state write outcome: " << model::name(restarted.recover()) << '\n';
}
} catch (const std::exception &error) {
std::cerr << error.what() << '\n';
return 1;
}
}
@@ -0,0 +1,59 @@
# Interrupted A/B installation model
This C++17 example follows [chapter 06](../../../docs/06-atomic-updates.md), [ADR-018](../../../adr/ADR-018-prepare-and-verify-before-activation.md), and [ADR-019](../../../adr/ADR-019-recover-interrupted-updates-from-durable-state.md). It models preparation and activation of B while confirmed system A remains available. It does not access devices or install files.
Build and run from the repository root with CMake 3.20 or later and a C++17 compiler. No third-party libraries are required.
```sh
cmake -S examples/simulations/interrupted-installation -B /tmp/ota-installation-build -DCMAKE_BUILD_TYPE=Debug
cmake --build /tmp/ota-installation-build
/tmp/ota-installation-build/installation-simulate all
ctest --test-dir /tmp/ota-installation-build --output-on-failure
```
With no argument, the runner also runs all scenarios. `--help` prints usage. Unknown arguments return a nonzero exit status.
## Reset Points
Each run starts from fresh storage. It performs the first N steps, destroys the installer, and creates another installer using the same simulated storage.
| Steps completed before reset | Last operation | Recovery action |
|---|---|---|
| 0 | None | Keep A; start preparation. |
| 1 | Save release and target intent | Keep A; restart preparation. |
| 2 | Write part of B | Keep A; restart preparation. |
| 3 | Complete writing B | Verify again; save prepared state; wait for activation policy. |
| 4 | Verify B | Repeat verification; save prepared state; wait for activation policy. |
| 5 | Save prepared state | Verify again; wait for activation policy. |
| 6 | Save trial request | Preserve trial and attempts; repair old progress. |
| 7 | Save trial-requested progress | Preserve trial and attempts. |
The runner also supplies three possible results of an interrupted boot-record write: retain the old record, retain the new record, or find no valid record. The last case requires separate recovery. These are assumed outcomes, not a redundant-record algorithm.
## Storage and Ownership
`Storage` represents persistent bytes and records in memory. Each modeled storage operation is already complete and durable when it returns. `Installer` holds a temporary verification result. Recreating it clears that result, so a new activation requires fresh verification.
Boot state owns selection and the trial limit. OTA progress can be old. When a trial request already exists, recovery repairs progress without rewriting B or renewing attempts. Recovery with A selected checks both the release identity and installed contents before accepting B as prepared.
Only one installer is active. A is initially confirmed; there is no earlier pending trial. B is writable only while boot state selects A. A remains unchanged. `recover()` does not activate B automatically; the caller still owns activation policy.
## Checks and Limits
[tests.cpp](tests.cpp) checks all reset points and their continuation, activation guards, loss of temporary verification, damaged contents, wrong release identity, all supplied boot-write outcomes, and repeated recovery without renewing attempts. Checks remain active in Release builds.
The model compares strings with fixed expected bytes. It has no signatures, real files, flushing, redundant records, or interruption inside a write. It does not model storage wear, concurrent installers, data migration, running processes, or later releases. Direct changes to `Storage` in tests represent faults between installer instances.
The boot record is simplified to A selected, B trial, or invalid. Attempts are recorded but not consumed here. The printed boot choice is the next lifecycle destination, not a real bootloader decision. Confirmation, attempt exhaustion, and recovery health checks remain in the separate [A/B trial boot example](../ab-trial-boot/readme.md); the programs are not integrated.
Real storage needs a defined durability contract and failure-injection tests on the target platform. This model checks ordering and recovery decisions only.
## Sources and Diagrams
- [simulation.hpp](simulation.hpp) and [simulation.cpp](simulation.cpp): storage, installer, and recovery rules.
- [main.cpp](main.cpp): scenario output.
- [CMakeLists.txt](CMakeLists.txt): build and CTest integration.
- [Preparation and activation sequence](../../../diagrams/plantuml/ab-installation-sequence.puml).
- [Installation recovery states](../../../diagrams/plantuml/ab-installation-recovery.puml).
Format C++ sources with the repository's root `.clang-format`.
@@ -0,0 +1,154 @@
#include "simulation.hpp"
#include <stdexcept>
namespace ota::installation {
const std::string &expected_contents() {
static const std::string contents = "release-2:kernel+device-tree+rootfs";
return contents;
}
const char *name(RecoveryAction action) {
switch (action) {
case RecoveryAction::RestartPreparation:
return "restart preparation; keep A";
case RecoveryAction::ReadyToActivate:
return "B verified again; wait for activation policy";
case RecoveryAction::PreserveTrial:
return "preserve saved B trial and attempts";
case RecoveryAction::SeparateRecovery:
return "stop; separate recovery required";
}
throw std::logic_error("Unknown recovery action");
}
const char *boot_choice(BootRecord record) {
switch (record) {
case BootRecord::ASelected:
return "A";
case BootRecord::BTrial:
return "B trial (attempt handling is outside this model)";
case BootRecord::Invalid:
return "separate recovery";
}
throw std::logic_error("Unknown boot record");
}
void Installer::require_writable() const {
if (storage_.boot != BootRecord::ASelected) {
throw std::logic_error("B is selected or boot state is invalid");
}
}
void Installer::begin() {
require_writable();
verified_ = false;
storage_.release = "release-2";
storage_.progress = Progress::Writing;
}
void Installer::write_partial() {
require_writable();
if (storage_.progress != Progress::Writing || storage_.release != "release-2") {
throw std::logic_error("Save matching intent before writing");
}
verified_ = false;
storage_.b_contents = expected_contents().substr(0, 8);
}
void Installer::finish_write() {
require_writable();
if (storage_.progress != Progress::Writing || storage_.release != "release-2") {
throw std::logic_error("Save matching intent before writing");
}
verified_ = false;
storage_.b_contents = expected_contents();
}
void Installer::verify() {
require_writable();
verified_ = false;
if (storage_.release != "release-2" || storage_.b_contents != expected_contents()) {
throw std::runtime_error("Installed release does not match expected contents");
}
verified_ = true;
}
void Installer::save_prepared() {
require_writable();
if (!verified_) throw std::logic_error("Verify B before saving prepared state");
storage_.progress = Progress::Prepared;
}
void Installer::request_trial(WriteResult result) {
require_writable();
if (!verified_ || storage_.progress != Progress::Prepared) {
throw std::logic_error("Verify B and save prepared state before activation");
}
// Supplied outcomes stand for the storage contract of boot control.
// OldRecord leaves all fields intact; NewRecord saves one complete record.
if (result == WriteResult::OldRecord) {
throw std::runtime_error("Interrupted request; old record remains");
}
if (result == WriteResult::InvalidRecord) {
storage_.boot = BootRecord::Invalid;
throw std::runtime_error("Interrupted request; no valid record remains");
}
storage_.boot = BootRecord::BTrial;
storage_.attempts_left = 2;
}
void Installer::save_requested() {
if (storage_.boot != BootRecord::BTrial) {
throw std::logic_error("No saved B trial request");
}
storage_.progress = Progress::TrialRequested;
}
RecoveryAction Installer::recover() {
verified_ = false;
// Boot state takes priority over progress. Never write B or renew attempts
// when a trial is already selected. Invalid boot state is not confirmation.
if (storage_.boot == BootRecord::Invalid) return RecoveryAction::SeparateRecovery;
if (storage_.boot == BootRecord::BTrial) {
save_requested();
return RecoveryAction::PreserveTrial;
}
if (storage_.release != "release-2" || storage_.b_contents != expected_contents()) {
return RecoveryAction::RestartPreparation;
}
verify();
save_prepared();
return RecoveryAction::ReadyToActivate;
}
void perform_step(Installer &installer, int step) {
switch (step) {
case 0:
installer.begin();
break;
case 1:
installer.write_partial();
break;
case 2:
installer.finish_write();
break;
case 3:
installer.verify();
break;
case 4:
installer.save_prepared();
break;
case 5:
installer.request_trial();
break;
case 6:
installer.save_requested();
break;
default:
throw std::logic_error("Unknown installation step");
}
}
}// namespace ota::installation
@@ -0,0 +1,60 @@
#pragma once
#include <string>
namespace ota::installation {
enum class Progress { Idle,
Writing,
Prepared,
TrialRequested };
enum class BootRecord { ASelected,
BTrial,
Invalid };
enum class WriteResult { OldRecord,
NewRecord,
InvalidRecord };
enum class RecoveryAction { RestartPreparation,
ReadyToActivate,
PreserveTrial,
SeparateRecovery };
// This object stands for persistent storage. Each assignment is a completed
// durable operation in the model, not a real storage implementation.
struct Storage {
std::string a_contents = "release-1";
std::string b_contents;
std::string release;
Progress progress = Progress::Idle;
BootRecord boot = BootRecord::ASelected;
int attempts_left = 0;
};
const std::string &expected_contents();
const char *name(RecoveryAction action);
const char *boot_choice(BootRecord record);
class Installer {
public:
explicit Installer(Storage &storage) : storage_(storage) {}
void begin();
void write_partial();
void finish_write();
void verify();
void save_prepared();
void request_trial(WriteResult result = WriteResult::NewRecord);
void save_requested();
RecoveryAction recover();
private:
void require_writable() const;
Storage &storage_;
// Lost when the installer is reconstructed after a simulated reset.
bool verified_ = false;
};
// Ordered operations; cut points are before step 0 and after each step.
constexpr int installation_steps = 7;
void perform_step(Installer &installer, int step);
}// namespace ota::installation
@@ -0,0 +1,148 @@
#include "simulation.hpp"
#include <iostream>
#include <stdexcept>
namespace model = ota::installation;
namespace {
void expect(bool condition, const char *message) {
if (!condition) throw std::runtime_error(message);
}
template<class Operation>
void expect_rejected(Operation operation) {
try {
operation();
} catch (const std::logic_error &) {
return;
}
throw std::runtime_error("Unsafe operation accepted");
}
void cut_points() {
for (int cut = 0; cut <= model::installation_steps; ++cut) {
model::Storage storage;
{
model::Installer installer(storage);
for (int step = 0; step < cut; ++step) model::perform_step(installer, step);
}
model::Installer restarted(storage);
const auto action = restarted.recover();
expect(storage.a_contents == "release-1", "A changed during installation");
if (cut < 3) {
expect(action == model::RecoveryAction::RestartPreparation, "Incomplete B accepted");
expect(storage.boot == model::BootRecord::ASelected, "Incomplete B selected");
expect_rejected([&] { restarted.request_trial(); });
// Exercise the documented restart path, not only its decision.
for (int step = 0; step < model::installation_steps; ++step) model::perform_step(restarted, step);
} else if (cut < 6) {
expect(action == model::RecoveryAction::ReadyToActivate, "Complete B not reverified");
expect(storage.boot == model::BootRecord::ASelected, "Recovery activated B without policy");
restarted.request_trial();
restarted.save_requested();
} else {
expect(action == model::RecoveryAction::PreserveTrial, "Saved trial lost");
expect(storage.progress == model::Progress::TrialRequested, "Old progress not repaired");
}
expect(storage.boot == model::BootRecord::BTrial, "Continuation did not reach trial");
expect(storage.b_contents == model::expected_contents(), "Selected B is incomplete");
expect(storage.a_contents == "release-1", "Continuation changed A");
storage.attempts_left = 1;// Boot control has already consumed an attempt.
model::Installer second_restart(storage);
expect(second_restart.recover() == model::RecoveryAction::PreserveTrial, "Repeated recovery lost trial");
expect(storage.attempts_left == 1, "Recovery renewed trial attempts");
expect_rejected([&] { second_restart.begin(); });
expect_rejected([&] { second_restart.write_partial(); });
expect_rejected([&] { second_restart.finish_write(); });
expect_rejected([&] { second_restart.request_trial(); });
}
}
void activation_guards() {
model::Storage storage;
model::Installer installer(storage);
expect_rejected([&] { installer.finish_write(); });
expect_rejected([&] { installer.save_requested(); });
installer.begin();
installer.finish_write();
expect_rejected([&] { installer.save_prepared(); });
installer.verify();
expect_rejected([&] { installer.request_trial(); });
installer.save_prepared();
model::Installer restarted(storage);
expect_rejected([&] { restarted.request_trial(); });
expect(restarted.recover() == model::RecoveryAction::ReadyToActivate, "Fresh verification failed");
restarted.request_trial();
}
void bad_contents_and_identity() {
for (bool wrong_release: {false, true}) {
model::Storage storage;
{
model::Installer installer(storage);
for (int step = 0; step < 5; ++step) model::perform_step(installer, step);
}
if (wrong_release) storage.release = "another-release";
else
storage.b_contents = "damaged";
model::Installer restarted(storage);
expect(restarted.recover() == model::RecoveryAction::RestartPreparation, "Stale prepared record trusted");
try {
restarted.verify();
throw std::logic_error("Bad release passed verification");
} catch (const std::runtime_error &) {
// Expected content or identity failure; logic_error escapes.
}
expect_rejected([&] { restarted.request_trial(); });
expect(storage.boot == model::BootRecord::ASelected, "Bad release selected");
}
}
void interrupted_boot_write() {
for (const auto result: {model::WriteResult::OldRecord, model::WriteResult::NewRecord, model::WriteResult::InvalidRecord}) {
model::Storage storage;
{
model::Installer installer(storage);
for (int step = 0; step < 5; ++step) model::perform_step(installer, step);
bool failed = false;
try {
installer.request_trial(result);
} catch (const std::runtime_error &) {
failed = true;
}
expect(failed == (result != model::WriteResult::NewRecord), "Wrong request result");
}
model::Installer restarted(storage);
const auto action = restarted.recover();
if (result == model::WriteResult::OldRecord) {
expect(action == model::RecoveryAction::ReadyToActivate, "Old boot record not retained");
expect(storage.attempts_left == 0, "Old record partly changed");
} else if (result == model::WriteResult::NewRecord) {
expect(action == model::RecoveryAction::PreserveTrial, "New boot record not retained");
expect(storage.attempts_left == 2, "New trial limit lost");
} else {
expect(action == model::RecoveryAction::SeparateRecovery, "Invalid boot state accepted");
expect(storage.progress == model::Progress::Prepared, "Invalid boot state changed progress");
expect_rejected([&] { restarted.begin(); });
expect_rejected([&] { restarted.request_trial(); });
}
expect(storage.a_contents == "release-1", "Boot record failure changed A");
}
}
}// namespace
int main() {
try {
cut_points();
activation_guards();
bad_contents_and_identity();
interrupted_boot_write();
std::cout << "All installation recovery checks passed\n";
} catch (const std::exception &error) {
std::cerr << "Test failed: " << error.what() << '\n';
return 1;
}
}