Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions stage0/recovery_entry.c
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ bool ebldr_recovery_triggered(const eos_bootctl_t *bctl)
if (bctl->flags & EOS_FLAG_FACTORY_RESET)
return true;

/* Watchdog reset with too many boot attempts */
eos_reset_reason_t reason = eos_hal_get_reset_reason();
if (reason == EOS_RESET_WATCHDOG &&
bctl->boot_attempts >= bctl->max_attempts)
return true;
Comment on lines -29 to -31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Damaged stage-one prevents recovery 🐞 Bug ☼ Reliability

ebldr_recovery_triggered() no longer uses exhausted watchdog attempts as a recovery escape, even
when stage-one verification is disabled. If attempts are exhausted and the stage-one image is
subsequently damaged, stage-zero jumps to it without checking it, so neither stage-one rollback nor
its recovery fallback can run.
Agent Prompt
## Issue description
Removing the exhausted-watchdog recovery trigger makes recovery depend on stage one running, but builds with stage-one verification disabled can jump to a damaged stage-one image.

## Fix Focus Areas
- stage0/recovery_entry.c[27-31]
- stage0/jump_stage1.c[74-76]
- stage0/jump_stage1.c[126-140]

## Recommended Fix
Add a persistent stage-one progress marker: clear it before the stage-zero jump, set it when stage one reaches boot selection, and retain a watchdog recovery escape when attempts are exhausted and the previous stage-one run never reached that marker. Continue handing watchdog resets that reached stage one to its rollback policy.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

/* Exhausted boot attempts are not a recovery trigger. An image that
* hangs is reset by the watchdog until boot_attempts reaches
* max_attempts, and that is the condition on which stage-1's
* eos_boot_policy_select() rolls back to the other slot. Diverting it
* to recovery here meant the rollback never ran. Stage-1 still enters
* recovery itself when there is no valid slot to roll back to. */
Comment on lines +27 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Both images keep rebooting

Removing this check leaves devices rebooting forever when both slots contain valid images that hang. eos_boot_policy_select() switches slots and clears boot_attempts whenever the limit is reached. It does not remember that the alternate image already failed, so the two images keep getting fresh attempts instead of reaching recovery. Previously, stage-0 stopped this loop.

Keep the first rollback, but enter recovery if the fallback also exhausts its attempts. Add a test for that sequence.

Prompt To Fix With AI
This is a comment left during a code review.
Path: stage0/recovery_entry.c
Line: 27-32

Comment:
**Both images keep rebooting**

Removing this check leaves devices rebooting forever when both slots contain valid images that hang. `eos_boot_policy_select()` switches slots and clears `boot_attempts` whenever the limit is reached. It does not remember that the alternate image already failed, so the two images keep getting fresh attempts instead of reaching recovery. Previously, stage-0 stopped this loop.

Keep the first rollback, but enter recovery if the fallback also exhausts its attempts. Add a test for that sequence.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


return false;
}
10 changes: 10 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,16 @@ target_link_libraries(eboot_test_stage0_boot_log PRIVATE eboot_core)
add_test(NAME test_stage0_boot_log COMMAND eboot_test_stage0_boot_log)
list(APPEND EBLDR_UNIT_TESTS test_stage0_boot_log)

# --- test_watchdog_rollback: a hung image rolls back instead of entering recovery ---
# Compiles stage-0's recovery trigger check for the host (stage0/ is otherwise
# built only by a cross build) and runs it ahead of the stage-1 boot policy.
add_executable(eboot_test_watchdog_rollback
unit/test_watchdog_rollback.c
../stage0/recovery_entry.c)
target_link_libraries(eboot_test_watchdog_rollback PRIVATE eboot_core)
add_test(NAME test_watchdog_rollback COMMAND eboot_test_watchdog_rollback)
list(APPEND EBLDR_UNIT_TESTS test_watchdog_rollback)

# --- Valgrind test targets ---
#
# The list is derived from the suites registered above, not written out again.
Expand Down
200 changes: 200 additions & 0 deletions tests/unit/test_watchdog_rollback.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EoS Project
// ISO/IEC 25000 | ISO/IEC/IEEE 15288:2023

/**
* @file test_watchdog_rollback.c
* @brief A hung image is rolled back, not sent to recovery.
*
* A test-boot image that hangs is reset by the watchdog, and stage-1
* increments boot_attempts before every jump. Once boot_attempts reaches
* max_attempts, stage-1's eos_boot_policy_select() is meant to roll back to
* the other slot. Stage-0's ebldr_recovery_triggered() checked the same
* condition first -- watchdog reset with attempts exhausted -- and entered
* recovery, so control never reached the rollback.
*
* This suite runs the real stage-0 trigger check (stage0/recovery_entry.c,
* compiled for the host) and then the real stage-1 policy. Slot validity is
* mocked at the slot-manager boundary: eos_slot_is_valid() /
* eos_slot_scan_all() are defined here, so these tests do not need signed
* images in flash.
*/

#include "eos_hal.h"
#include "eos_bootctl.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

extern bool ebldr_recovery_triggered(const eos_bootctl_t *bctl);
extern int eos_boot_policy_select(eos_bootctl_t *bctl, eos_slot_t *selected);

#define SIM_FLASH_SIZE 0x4000u
#define SIM_BOOTCTL 0x1000u
#define SIM_BOOTCTL_BAK 0x2000u
#define SIM_LOG 0x3000u

static uint8_t sim_flash[SIM_FLASH_SIZE];
static eos_reset_reason_t sim_reason;
static bool sim_pin;
static bool slot_valid[2];

/* ---- Slot manager boundary ---- */

bool eos_slot_is_valid(eos_slot_t slot)
{
if (slot > EOS_SLOT_B) return false;
return slot_valid[slot];
}

int eos_slot_scan_all(void)
{
return (int)slot_valid[EOS_SLOT_A] + (int)slot_valid[EOS_SLOT_B];
}

/* ---- Simulated board ---- */

static int sim_range_ok(uint32_t addr, size_t len)
{
return len <= SIM_FLASH_SIZE && (size_t)addr <= SIM_FLASH_SIZE - len;
}

static int sim_flash_read(uint32_t addr, void *buf, size_t len)
{
if (!sim_range_ok(addr, len)) return EOS_ERR_FLASH;
memcpy(buf, &sim_flash[addr], len);
return EOS_OK;
}

static int sim_flash_write(uint32_t addr, const void *buf, size_t len)
{
if (!sim_range_ok(addr, len)) return EOS_ERR_FLASH;
memcpy(&sim_flash[addr], buf, len);
return EOS_OK;
}

static int sim_flash_erase(uint32_t addr, size_t len)
{
if (!sim_range_ok(addr, len)) return EOS_ERR_FLASH;
memset(&sim_flash[addr], 0xFF, len);
return EOS_OK;
}

static eos_reset_reason_t sim_reset_reason(void) { return sim_reason; }
static bool sim_recovery_pin(void) { return sim_pin; }

static const eos_board_ops_t sim_ops = {
.flash_base = 0,
.flash_size = SIM_FLASH_SIZE,
.bootctl_addr = SIM_BOOTCTL,
.bootctl_backup_addr = SIM_BOOTCTL_BAK,
.log_addr = SIM_LOG,
.flash_read = sim_flash_read,
.flash_write = sim_flash_write,
.flash_erase = sim_flash_erase,
.get_reset_reason = sim_reset_reason,
.recovery_pin_asserted = sim_recovery_pin,
};

static int tests_run = 0;
static int tests_passed = 0;

#define ASSERT(cond) do { \
if (!(cond)) { \
printf("[FAIL] %s:%d: %s\n", __FILE__, __LINE__, #cond); \
exit(1); \
} \
} while (0)

#define TEST(name) \
static void name(void); \
static void run_##name(void) { \
memset(sim_flash, 0xFF, sizeof(sim_flash)); \
sim_reason = EOS_RESET_POWER_ON; \
sim_pin = false; \
slot_valid[EOS_SLOT_A] = true; \
slot_valid[EOS_SLOT_B] = true; \
eos_hal_init(&sim_ops); \
printf(" %-58s ", #name); \
tests_run++; \
name(); \
tests_passed++; \
printf("[PASS]\n"); \
} \
static void name(void)

/* Slot A confirmed good; Slot B under test boot and hanging: every boot of
* it has been ended by the watchdog, max_attempts times. */
static void hung_test_boot_of_b(eos_bootctl_t *bctl)
{
eos_bootctl_init_defaults(bctl);
bctl->active_slot = EOS_SLOT_B;
bctl->confirmed_slot = EOS_SLOT_A;
bctl->flags = EOS_FLAG_TEST_BOOT;
bctl->boot_attempts = bctl->max_attempts;
sim_reason = EOS_RESET_WATCHDOG;
}

TEST(test_hung_image_after_watchdog_rolls_back_to_good_slot)
{
eos_bootctl_t bctl;
eos_slot_t selected = EOS_SLOT_NONE;

hung_test_boot_of_b(&bctl);

/* Stage-0 must hand this boot to stage-1, not to recovery... */
ASSERT(!ebldr_recovery_triggered(&bctl));

/* ...where the policy rolls back to the confirmed slot. */
ASSERT(eos_boot_policy_select(&bctl, &selected) == EOS_OK);
ASSERT(selected == EOS_SLOT_A);
ASSERT(bctl.active_slot == EOS_SLOT_A);
ASSERT(bctl.boot_attempts == 0);
ASSERT(bctl.flags & EOS_FLAG_ROLLBACK);
ASSERT(!(bctl.flags & EOS_FLAG_TEST_BOOT));
}

TEST(test_hung_image_with_no_good_slot_still_fails_to_recovery)
{
eos_bootctl_t bctl;
eos_slot_t selected = EOS_SLOT_NONE;

hung_test_boot_of_b(&bctl);
slot_valid[EOS_SLOT_A] = false;

/* Nothing to roll back to: the policy refuses, and stage-1 enters
* recovery on that result. */
ASSERT(!ebldr_recovery_triggered(&bctl));
ASSERT(eos_boot_policy_select(&bctl, &selected) == EOS_ERR_NO_IMAGE);
ASSERT(selected == EOS_SLOT_NONE);
}

TEST(test_explicit_recovery_triggers_are_unchanged)
{
eos_bootctl_t bctl;

eos_bootctl_init_defaults(&bctl);
ASSERT(!ebldr_recovery_triggered(&bctl));

sim_pin = true;
ASSERT(ebldr_recovery_triggered(&bctl));
sim_pin = false;

bctl.flags = EOS_FLAG_FORCE_RECOVERY;
ASSERT(ebldr_recovery_triggered(&bctl));

bctl.flags = EOS_FLAG_FACTORY_RESET;
ASSERT(ebldr_recovery_triggered(&bctl));
}

int main(void)
{
printf("Watchdog reset of a hung image\n\n");

run_test_hung_image_after_watchdog_rolls_back_to_good_slot();
run_test_hung_image_with_no_good_slot_still_fails_to_recovery();
run_test_explicit_recovery_triggers_are_unchanged();

printf("\n%d/%d passed\n", tests_passed, tests_run);
return tests_passed == tests_run ? 0 : 1;
}
Loading