From 89886ba0209677050180d7195f5e8884294ab1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Garc=C3=ADa=20Rodr=C3=ADguez?= Date: Fri, 13 Nov 2026 15:05:37 +0100 Subject: [PATCH 1/7] feat(kaos): Keep a fetch's card up for the install it announces A host that installs an app over the air sends `appdata_get` and then `install`, and the glass shows the gap between them: the fetch's card closes when the folder is written and opens again when the install reports its 0 %, so the person watching sees the board go back to the launcher for a moment, as if nothing were coming. `appdata_get` takes `install=on` to say the install comes next. The fetch is then the first step of that install, and its card is the install's: whatever the fetch's end, neither the done nor the failure goes out, and AO_AppMgr holds the card at 0 % in its idle state, saying it again every second so the LED stays up. The install that begins takes it over with the fetch's face still on it; one refused before it begins, anything else AO_AppMgr turns to, or KODE_APPMGR_HOLD_MS with no install, ends it with the unnamed done. A fetch that fails leaves no failure sheet, since the app's data is what the install looks like, never what lets it happen. The answer to the fetch is the same with or without the key, and a fetch without it is unchanged. An older board refuses the key with `code=3 msg=install`, so a host asks again without it. Co-Authored-By: Claude Opus 5.5 --- components/kaos/appmgr/src/appmgr_ao.c | 162 ++++++++++-- components/kaos/console/src/console_ao.c | 24 +- components/kaos/core/include/kaos.h | 13 +- components/kqp/include/kqp_config.h | 5 + components/kqp/include/kqp_signals.h | 4 + tests/host/cases/test_appmgr_ao.c | 304 ++++++++++++++++++++++- tests/host/cases/test_console_appdata.c | 44 ++++ 7 files changed, 521 insertions(+), 35 deletions(-) diff --git a/components/kaos/appmgr/src/appmgr_ao.c b/components/kaos/appmgr/src/appmgr_ao.c index bdaf84e..8dfefa6 100644 --- a/components/kaos/appmgr/src/appmgr_ao.c +++ b/components/kaos/appmgr/src/appmgr_ao.c @@ -79,6 +79,9 @@ * AO_Power takes the LED's orange pulse back after three seconds without a * report, and the glass's write card has nothing to follow between two. */ #define APPMGR_WAIT_BEAT_TICKS KQP_TICKS_PER_MS(1000U) +/* How long a fetch's card waits for the install the fetch announced + * (appmgr_appdata_get_request()'s `install`). */ +#define APPMGR_HOLD_TICKS KQP_TICKS_PER_MS(KODE_APPMGR_HOLD_MS) /* How long the card may take over one copy before it is given up on: a base * that covers mounting and a slow counterfeit card, plus a second for every * 128 KB, so a 13 MB extent gets nearly three minutes and a small one a @@ -535,6 +538,16 @@ typedef struct { uint8_t get_tries; /**< Times the put was asked again after a * busy refusal. */ bool get_open; /**< A transfer is open with AO_Net. */ + bool get_install; /**< An install follows: the card is its. */ + + /* ---- The card a fetch kept up for the install it announced ----------- + * Between `appdata_get install=on` and the install the host sends next, + * the glass would empty and fill again: the fetch's card stays at 0 %, + * said again every second on `beat`, while AppMgr_idle waits. The + * install that begins takes the card over (hold_release()); anything + * else that idle turns to, or `hold` going off, ends it (hold_end()). */ + QTimeEvt hold; /**< How long the card waits (APP_HOLD_SIG). */ + bool held; /**< The card is up, waiting for an install. */ } AppMgr; static AppMgr l_appMgr; @@ -693,7 +706,7 @@ bool appmgr_keep_only_request(const char *project, QActive *who) } kode_status_t appmgr_appdata_get_request(const char *project, const char *url, - QActive *who) + bool install, QActive *who) { if ( (NULL == project) || (NULL == url) || (NULL == who) || (strlen(url) >= (size_t)KODE_APPDATA_URL_MAX) ) @@ -708,7 +721,8 @@ kode_status_t appmgr_appdata_get_request(const char *project, const char *url, } (void)snprintf(e->project, sizeof(e->project), "%s", project); (void)snprintf(e->url, sizeof(e->url), "%s", url); - e->who = who; + e->who = who; + e->install = install; if ( !QACTIVE_POST_X(AO_AppMgr, &e->super, 0U, NULL) ) { KLOG_W(TAG, "AO_AppMgr's queue is full; the fetch of a data folder " @@ -3150,6 +3164,37 @@ static void beat_say(AppMgr * const me) (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); } +/** + * @brief Let go of the card a fetch kept up, publishing nothing. + * + * What the install that took it over says from here is what the glass shows: + * from the fetch's 0 % to the install's, the card never empties. + * + * @param me The AO. + */ +static void hold_release(AppMgr * const me) +{ + me->held = false; + (void)QTimeEvt_disarm(&me->hold); + (void)QTimeEvt_disarm(&me->beat); +} + +/** + * @brief End the card a fetch kept up, when it is still up. + * + * The done names no app, as the fetch's own would have: nothing was installed. + * + * @param me The AO. + */ +static void hold_end(AppMgr * const me) +{ + if ( me->held ) + { + hold_release(me); + QACTIVE_PUBLISH(&s_flash_done_evt.super, &me->super); + } +} + /** * @brief Write one delivered chunk into the region. * @return true while more chunks are wanted. @@ -3332,6 +3377,7 @@ void AppMgr_ctor(void) QTimeEvt_ctorX(&me->pacer, &me->super, APP_FLASH_CHUNK_SIG, 0U); QTimeEvt_ctorX(&me->idle, &me->super, APP_RECV_IDLE_SIG, 0U); QTimeEvt_ctorX(&me->beat, &me->super, APP_BEAT_SIG, 0U); + QTimeEvt_ctorX(&me->hold, &me->super, APP_HOLD_SIG, 0U); QTimeEvt_ctorX(&me->undo_tmo, &me->super, APP_UNDO_STEP_SIG, 0U); QTimeEvt_ctorX(&me->answer, &me->super, APP_RECV_LAUNCH_SIG, 0U); QTimeEvt_ctorX(&me->tidy, &me->super, APP_TIDY_TIMEOUT_SIG, 0U); @@ -3904,6 +3950,8 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) AppFlashEvt const * const req = Q_EVT_CAST(AppFlashEvt); kapps_dir_t const *d = kdrv_apps_dir(); int const idx = appmgr_find_idx(d, req->file); + + hold_end(me); /* before anything this request publishes */ if ( 0 > idx ) { KLOG_E(TAG, "'%s' is not installed - `app list` knows what is", @@ -3920,10 +3968,15 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) break; } case APP_ERASE_REQ_SIG: { + hold_end(me); /* before anything this request publishes */ status = erase_begin(me, Q_EVT_CAST(AppFlashEvt)); break; } case APPDATA_GET_REQ_SIG: { + /* Another fetch is another app's, and raises its own card: the + * held one ends first, or the new 0 % would reach the glass + * under the old face and the done after it would close it. */ + hold_end(me); status = get_begin(me, Q_EVT_CAST(AppDataGetReqEvt)); break; } @@ -3932,6 +3985,7 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) break; } case APP_KEEP_ONLY_REQ_SIG: { + hold_end(me); /* before anything this request publishes */ status = keep_only_ask(me, e); break; } @@ -4140,11 +4194,52 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) status = Q_TRAN(&AppMgr_adopting); break; } + case Q_ENTRY_SIG: { + if ( me->held ) + { + /* A fetch that announced an install ended on the way here. */ + (void)QTimeEvt_rearm(&me->hold, APPMGR_HOLD_TICKS); + (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); + } + status = Q_HANDLED(); + break; + } + case APP_BEAT_SIG: { + /* Only the held card's: a beat queued behind another state's + * last disarm has nothing to say here. */ + if ( me->held ) + { + progress_publish(me, 0U); + (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); + } + status = Q_HANDLED(); + break; + } + case APP_HOLD_SIG: { + if ( me->held ) + { + KLOG_W(TAG, "the install '%s' was fetched for did not come; " + "its card ends", me->project); + hold_end(me); + } + status = Q_HANDLED(); + break; + } + case Q_EXIT_SIG: { + /* Whatever else takes this AO out of idle - a question about the + * card, an adoption - is not the install the card waited for: + * one that was took the card over below. The requests that + * publish on their way in end the hold in their own case. */ + hold_end(me); + status = Q_HANDLED(); + break; + } #if CONFIG_KODE_LOWPOWER case LOWPOWER_ROUND_SIG: { - /* Idle: no image is being written, kept, received or launched. */ + /* Idle: no image is being written, kept, received or launched; + * a card waiting for its install is the one thing in hand. */ lowpower_ready(me->lp_who, Q_EVT_CAST(LowPowerRoundEvt)->round, - false, NULL); + me->held, me->held ? "install announced" : NULL); status = Q_HANDLED(); break; } @@ -4154,6 +4249,21 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) break; } } + /* The card a fetch kept up is the next install's, and only an install + * that begins takes it over; one refused before it began ends it here, + * where the refusal is known, rather than at the deadline. */ + if ( me->held && (((QSignal)FLASH_REQ_SIG == e->sig) + || ((QSignal)APP_RECV_REQ_SIG == e->sig)) ) + { + if ( Q_RET_TRAN == status ) + { + hold_release(me); + } + else + { + hold_end(me); + } + } return status; } @@ -5351,6 +5461,36 @@ static void get_open_file(AppMgr * const me, const char *file) net_stream_open_request(url, &me->super); } +/** + * @brief End the card a fetch raised, or leave it up for the install it + * announced. + * + * The done names no app, as a park's does: nothing was installed. A fetch + * asked with `install` publishes neither, whatever its end: the folder is + * what the install looks like, never what lets it happen, and the host sends + * the install all the same. AppMgr_idle keeps the card up for it. + * + * @param me The AO. + * @param ok The folder was written. + */ +static void get_card_end(AppMgr * const me, bool ok) +{ + if ( me->get_install ) + { + me->get_install = false; + me->held = true; /* AppMgr_idle's entry arms its timers */ + KLOG_N(TAG, "the card of '%s' waits for its install", me->get_project); + } + else if ( ok ) + { + QACTIVE_PUBLISH(&s_flash_done_evt.super, &me->super); + } + else + { + QACTIVE_PUBLISH(&s_flash_fail_evt.super, &me->super); + } +} + /** * @brief End a fetch that did not make a folder. * @@ -5367,7 +5507,7 @@ static QState get_fail(AppMgr * const me, appdata_get_why_t why) } get_say(me->get_project, why); get_reply(me->get_who, me->get_project, why, false); - QACTIVE_PUBLISH(&s_flash_fail_evt.super, &me->super); /* the card the fetch raised ends too */ + get_card_end(me, false); return Q_TRAN(&AppMgr_idle); } @@ -5592,6 +5732,7 @@ static QState get_begin(AppMgr * const me, AppDataGetReqEvt const * const req) me->get_icon_len = 0U; me->get_tries = 0U; me->get_open = false; + me->get_install = req->install; (void)snprintf(me->get_project, sizeof(me->get_project), "%s", req->project); (void)snprintf(me->get_url, (size_t)KODE_APPDATA_URL_MAX, "%s", req->url); KLOG_N(TAG, "fetching the data folder of '%s'", me->get_project); @@ -5694,16 +5835,7 @@ static QState AppMgr_fetching(AppMgr * const me, QEvt const * const e) get_reply(me->get_who, me->get_project, why, (APPDATA_GET_OK == why) && (0U != me->get_icon_len)); - /* The card the fetch raised ends too; the done names no - * app, as a park's does: nothing was installed. */ - if ( APPDATA_GET_OK == why ) - { - QACTIVE_PUBLISH(&s_flash_done_evt.super, &me->super); - } - else - { - QACTIVE_PUBLISH(&s_flash_fail_evt.super, &me->super); - } + get_card_end(me, APPDATA_GET_OK == why); status = Q_TRAN(&AppMgr_idle); } } diff --git a/components/kaos/console/src/console_ao.c b/components/kaos/console/src/console_ao.c index 6b24f1f..cb47dc8 100644 --- a/components/kaos/console/src/console_ao.c +++ b/components/kaos/console/src/console_ao.c @@ -73,7 +73,8 @@ KAOS_WEAK void kaos_net_start(void); KAOS_WEAK void appdata_request(const char *project, QActive *who); KAOS_WEAK kode_status_t appdata_rm_request(const char *project, QActive *who); KAOS_WEAK kode_status_t appmgr_appdata_get_request(const char *project, - const char *url, QActive *who); + const char *url, bool install, + QActive *who); KAOS_WEAK const uint8_t *storage_buf(uint8_t idx); KAOS_WEAK void storage_close_request(void); KAOS_WEAK void storage_desc_request(const char *name, QActive *who); @@ -2239,9 +2240,11 @@ static void h_appdata_rm(Console *me, const kcmd_t *c, const karg_val_t *v) * answers every fetch, so this only bounds a lost answer. */ #define KCLI_APPDATA_GET_MS ((2U * KODE_APPMGR_OPEN_MS) + APPMGR_TIDY_MS + KCLI_CMD_TIMEOUT_MS) -/** @brief appdata_get name= url=: fetch the app's data folder - * from `app.json` and `icon.k64`; closed by - * APPDATA_GET_RSP_SIG, or at once when the request is refused. */ +/** @brief appdata_get name= url= [install=on]: fetch the app's + * data folder from `app.json` and `icon.k64`; closed by + * APPDATA_GET_RSP_SIG, or at once when the request is refused. + * `install=on` says an install of the app comes next, and the card the + * fetch raises stays up for it (appmgr_appdata_get_request()). */ static void h_appdata_get(Console *me, const kcmd_t *c, const karg_val_t *v) { if ( console_owner_absent(&kaos_storage_start) ) @@ -2258,7 +2261,9 @@ static void h_appdata_get(Console *me, const kcmd_t *c, const karg_val_t *v) return; } cmd_begin(me, KCLI_APPDATA_GET, c->verb); - kode_status_t const rc = appmgr_appdata_get_request(v[0].s, v[1].s, &me->super); + bool const install = v[2].present && v[2].b; + kode_status_t const rc = appmgr_appdata_get_request(v[0].s, v[1].s, install, + &me->super); if ( KODE_OK != rc ) { /* Nothing was posted, so nobody will answer. */ @@ -6260,9 +6265,12 @@ _Static_assert((sizeof(A_EXP) / sizeof(A_EXP[0])) <= (size_t)KCLI_MAX_KEYS, static const karg_spec_t A_NAME[] = { { .key = "name", .required = true, .type = KARG_STRING }, }; +/* `install=on` when the host sends the app's install next: the fetch's card + * waits for it instead of closing between the two. */ static const karg_spec_t A_APPDATA_GET[] = { - { .key = "name", .required = true, .type = KARG_STRING }, - { .key = "url", .required = true, .type = KARG_STRING }, + { .key = "name", .required = true, .type = KARG_STRING }, + { .key = "url", .required = true, .type = KARG_STRING }, + { .key = "install", .required = false, .type = KARG_BOOL, .def = "off" }, }; /* `ls` may be typed bare (the cwd). `cd` and `stat` need a path, so a * mistyped key is not taken as the cwd. */ @@ -6452,7 +6460,7 @@ static const kcmd_t CMDS[] = { { "appdata_ls", NULL, 0U, h_appdata_ls }, { "appdata_info", A_NAME, 1U, h_appdata_info }, { "appdata_rm", A_NAME, 1U, h_appdata_rm }, - { "appdata_get", A_APPDATA_GET, 2U, h_appdata_get }, + { "appdata_get", A_APPDATA_GET, 3U, h_appdata_get }, { "variable", A_VARIABLE, 2U, h_variable }, { "variable_list", NULL, 0U, h_variable_list }, { "function", A_FUNCTION, 2U, h_function }, diff --git a/components/kaos/core/include/kaos.h b/components/kaos/core/include/kaos.h index e914ddb..633174a 100644 --- a/components/kaos/core/include/kaos.h +++ b/components/kaos/core/include/kaos.h @@ -3877,6 +3877,8 @@ typedef struct { * names are added to it. */ QActive *who; /**< Where APPDATA_GET_RSP_SIG is * posted. */ + bool install; /**< An install of the app follows: + * the card stays up for it. */ } AppDataGetReqEvt; /** @brief How a fetch of one app's data folder ended (APPDATA_GET_RSP_SIG). */ @@ -3902,16 +3904,25 @@ typedef struct { * and again every second while it waits on the network, ended by an unnamed * APP_FLASH_DONE_SIG or by APP_FLASH_FAIL_SIG. * + * A fetch asked with @p install is the first step of an install, and its card + * is the install's: whatever the fetch's end, neither event goes out. The card + * stays at 0 %, said again every second, until the next install request takes + * it over - its own reports carry it from there, so the glass never empties + * between the two - or until AO_AppMgr turns to anything else, a refused + * install included, or KODE_APPMGR_HOLD_MS goes by; then the unnamed + * APP_FLASH_DONE_SIG ends it. The answer to the fetch is the same either way. + * * @param project The app's project name, as its image declares it. * @param url The base URL, ending where the file names begin; shorter than * KODE_APPDATA_URL_MAX. + * @param install true when an install of the app follows the fetch. * @param who The Active Object the answer is posted to. Not NULL. * @return KODE_OK when the request is posted and an answer is owed; KODE_ERROR * for a NULL argument or a URL that does not fit; KODE_NO_RESOURCES when * the pool is dry or the queue full. Nothing was posted for the last two. */ kode_status_t appmgr_appdata_get_request(const char *project, const char *url, - QActive *who); + bool install, QActive *who); /* ---- AO_Boot: owner of otadata AND the apps boot request ---------------- * kodeOS lives in `factory` or its OTA slot; apps live in the `apps` region. diff --git a/components/kqp/include/kqp_config.h b/components/kqp/include/kqp_config.h index b4f5b70..cb24b8d 100644 --- a/components/kqp/include/kqp_config.h +++ b/components/kqp/include/kqp_config.h @@ -1631,6 +1631,11 @@ _Static_assert(KODE_IR_TEST_BUDGET_MS > (KODE_IR_LINK_WAIT_MS + (KODE_IR_TEST_TR #define KODE_APPMGR_PUT_TRIES 3U /** @brief The pause before each of those tries, in ms. */ #define KODE_APPMGR_PUT_RETRY_MS 500U +/** @brief How long the card of a fetch that announced an install + * (`appdata_get install=on`) waits for that install to begin, in ms. + * The host sends it the moment the fetch is answered; this only bounds + * a host that never does, so the card does not stand for ever. */ +#define KODE_APPMGR_HOLD_MS 15000U /* ---- The factory reset's budgets ---------------------------------------- * AO_Boot walks the reset one step at a time and arms one timer per step; a diff --git a/components/kqp/include/kqp_signals.h b/components/kqp/include/kqp_signals.h index 31862d4..8704c0e 100644 --- a/components/kqp/include/kqp_signals.h +++ b/components/kqp/include/kqp_signals.h @@ -550,6 +550,10 @@ enum KodeSignals { * waiting on the network has gone by - * report the write's percent again, so the * glass and the LED stay up. */ + APP_HOLD_SIG, /**< AO_AppMgr private QTimeEvt: the install + * an `appdata_get install=on` announced + * did not begin in KODE_APPMGR_HOLD_MS - + * end the card the fetch kept up for it. */ FLASH_REQ_SIG, /**< AO_AppMgr: one source-to-sink transfer * (FlashReqEvt). The only way to ask for * an image to be written. */ diff --git a/tests/host/cases/test_appmgr_ao.c b/tests/host/cases/test_appmgr_ao.c index 10b13f9..dcedd2d 100644 --- a/tests/host/cases/test_appmgr_ao.c +++ b/tests/host/cases/test_appmgr_ao.c @@ -9331,8 +9331,11 @@ TEST_CASE("a kodeOS write names no project", "[appmgr_ao][project]") #define GET_URL "http://example.invalid/apps/snake/" -/** @brief Ask AO_AppMgr for @p project's folder from @p url, answered to the spy. */ -static void ask_get(const char *project, const char *url) +/** + * @brief Ask AO_AppMgr for @p project's folder from @p url, answered to the + * spy; @p install says an install of the app follows. + */ +static void ask_get_as(const char *project, const char *url, bool install) { AppDataGetReqEvt e; @@ -9340,10 +9343,17 @@ static void ask_get(const char *project, const char *url) e.super = (QEvt)QEVT_INITIALIZER(APPDATA_GET_REQ_SIG); (void)snprintf(e.project, sizeof(e.project), "%s", project); (void)snprintf(e.url, sizeof(e.url), "%s", url); - e.who = ao_spy(); + e.who = ao_spy(); + e.install = install; QASM_DISPATCH(AO_AppMgr, &e.super, 0U); } +/** @brief Ask AO_AppMgr for @p project's folder from @p url, answered to the spy. */ +static void ask_get(const char *project, const char *url) +{ + ask_get_as(project, url, false); +} + /** @brief What AO_Net's queue holds: the opens and closes, and the last URL opened. */ typedef struct { uint32_t opens; @@ -9442,14 +9452,15 @@ const uint8_t *host_net_stream_buf(uint8_t idx) /** * @brief Run a fetch of "snake" up to the put it asks AO_Storage for: app.json - * arrives whole in one chunk and names no icon. + * arrives whole in one chunk and names no icon. @p install says an + * install of the app follows. */ -static void fetch_to_the_put(void) +static void fetch_to_the_put_as(bool install) { StorageDataEvt d; uint32_t const len = (uint32_t)(sizeof(GET_JSON_BODY) - 1U); - ask_get("snake", GET_URL); + ask_get_as("snake", GET_URL, install); (void)net_seen(); fetch_opened((uint8_t)KODE_OK, len); @@ -9463,6 +9474,12 @@ static void fetch_to_the_put(void) QASM_DISPATCH(AO_AppMgr, &d.super, 0U); } +/** @brief fetch_to_the_put_as() for a fetch that announces nothing. */ +static void fetch_to_the_put(void) +{ + fetch_to_the_put_as(false); +} + /** * @brief AO_Storage reads the put AO_AppMgr asked for: it must name "snake" * and carry app.json whole, and it gives the buffer back. @@ -9833,15 +9850,15 @@ TEST_CASE("the fetch's request refuses what it cannot post", (void)memset(long_url, 'u', sizeof(long_url) - 1U); long_url[sizeof(long_url) - 1U] = '\0'; - TEST_ASSERT_EQUAL_INT(KODE_ERROR, appmgr_appdata_get_request(NULL, GET_URL, ao_spy())); - TEST_ASSERT_EQUAL_INT(KODE_ERROR, appmgr_appdata_get_request("snake", NULL, ao_spy())); - TEST_ASSERT_EQUAL_INT(KODE_ERROR, appmgr_appdata_get_request("snake", GET_URL, NULL)); + TEST_ASSERT_EQUAL_INT(KODE_ERROR, appmgr_appdata_get_request(NULL, GET_URL, false, ao_spy())); + TEST_ASSERT_EQUAL_INT(KODE_ERROR, appmgr_appdata_get_request("snake", NULL, false, ao_spy())); + TEST_ASSERT_EQUAL_INT(KODE_ERROR, appmgr_appdata_get_request("snake", GET_URL, false, NULL)); TEST_ASSERT_EQUAL_INT_MESSAGE(KODE_ERROR, - appmgr_appdata_get_request("snake", long_url, ao_spy()), + appmgr_appdata_get_request("snake", long_url, false, ao_spy()), "a URL that does not fit the request was cut, not refused"); TEST_ASSERT_EQUAL_UINT(0U, (unsigned)uxQueueMessagesWaiting(AO_AppMgr->eQueue)); - TEST_ASSERT_EQUAL_INT(KODE_OK, appmgr_appdata_get_request("snake", GET_URL, ao_spy())); + TEST_ASSERT_EQUAL_INT(KODE_OK, appmgr_appdata_get_request("snake", GET_URL, false, ao_spy())); (void)qf_support_pump(AO_AppMgr, 8U); net_seen_t const n = net_seen(); TEST_ASSERT_EQUAL_UINT(1U, n.opens); @@ -9882,3 +9899,268 @@ TEST_CASE("a fetch of an app's data folder shows from its first moment and ends QActive_unsubscribe(ao_spy(), APP_FLASH_FAIL_SIG); fetch_end(); } + +/* ---- A fetch that announces an install -------------------------------------- + * `appdata_get install=on`: the host sends the app's install the moment the + * fetch is answered, and the glass must not empty between the two. The fetch's + * card stays at 0 % for the install; the install that begins takes it over, and + * anything else - a refusal, another request, the deadline - ends it with the + * unnamed done. */ + +/** @brief KODE_APPMGR_HOLD_MS in QP ticks, as appmgr_ao.c has it. */ +#define APPMGR_HOLD_TEST_TICKS (KODE_APPMGR_HOLD_MS / 10U) + +/** @brief What the card was told, in the order the spy holds it. */ +typedef struct { + unsigned reports; /**< APP_FLASH_PROGRESS_SIG. */ + unsigned dones; /**< APP_FLASH_DONE_SIG. */ + unsigned fails; /**< APP_FLASH_FAIL_SIG. */ + unsigned answers; /**< APPDATA_GET_RSP_SIG. */ + uint8_t why; /**< The last answer's appdata_get_why_t. */ + enum_t first; /**< The first of the card's three, or 0. */ + char project[KODE_APP_PROJECT_MAX]; /**< The last report's. */ +} card_seen_t; + +/** @brief Read what the spy holds about the card and the fetch, draining it. */ +static card_seen_t card_seen(void) +{ + card_seen_t seen; + + (void)memset(&seen, 0, sizeof(seen)); + for ( const QEvt *e = ao_spy_take(); NULL != e; e = ao_spy_take() ) + { + enum_t const sig = (enum_t)e->sig; + + if ( sig == APP_FLASH_PROGRESS_SIG ) + { + ++seen.reports; + (void)snprintf(seen.project, sizeof(seen.project), "%s", + ((AppProgressEvt const *)e)->project); + } + else if ( sig == APP_FLASH_DONE_SIG ) + { + ++seen.dones; + } + else if ( sig == APP_FLASH_FAIL_SIG ) + { + ++seen.fails; + } + else if ( sig == APPDATA_GET_RSP_SIG ) + { + ++seen.answers; + seen.why = ((AppDataGetRspEvt const *)e)->why; + continue; + } + else + { + continue; + } + if ( 0 == seen.first ) + { + seen.first = sig; + } + } + return seen; +} + +/** @brief The spy hears the card: every report, done and failure. */ +static void card_listen(void) +{ + ao_spy_subscribe(APP_FLASH_PROGRESS_SIG); + ao_spy_subscribe(APP_FLASH_DONE_SIG); + ao_spy_subscribe(APP_FLASH_FAIL_SIG); +} + +/** @brief A fetch of "snake" that announced an install, answered ok, its card held. */ +static void card_held(void) +{ + fetch_fresh(); + card_listen(); + fetch_to_the_put_as(true); + fetch_put_is_whole(); + put_rsp("snake", KODE_OK); + + card_seen_t const seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.answers, "the fetch is answered as ever"); + TEST_ASSERT_EQUAL_INT(APPDATA_GET_OK, seen.why); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, "the card closed before the install"); + TEST_ASSERT_EQUAL_UINT(0U, seen.fails); +} + +TEST_CASE("a fetch that announces nothing ends its card with the unnamed done", + "[appmgr_ao][appdata_get][hold]") +{ + fetch_fresh(); + card_listen(); + fetch_to_the_put(); + fetch_put_is_whole(); + put_rsp("snake", KODE_OK); + + card_seen_t const seen = card_seen(); + TEST_ASSERT_EQUAL_UINT(1U, seen.answers); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.dones, "the card the fetch raised is left up"); + run_ticks(3U * APPMGR_WAIT_BEAT_TICKS); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, card_seen().reports, + "a fetch that announced nothing kept saying its percent"); + + QActive_unsubscribeAll(ao_spy()); + fetch_end(); +} + +TEST_CASE("a fetch that announces an install keeps its card at 0 % after its answer", + "[appmgr_ao][appdata_get][hold]") +{ + card_held(); + + run_ticks((2U * APPMGR_WAIT_BEAT_TICKS) + 10U); + card_seen_t const seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(2U, seen.reports, + "the held card fell silent, and the LED with it"); + TEST_ASSERT_EQUAL_STRING("snake", seen.project); + TEST_ASSERT_EQUAL_UINT(0U, seen.dones); + + QActive_unsubscribeAll(ao_spy()); + fetch_end(); +} + +TEST_CASE("the install a fetch announced takes its card over without closing it", + "[appmgr_ao][appdata_get][hold]") +{ + card_held(); + + ask_https("https://example.invalid/snake.bin"); + card_seen_t seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, + "the card closed between the fetch and its install"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.reports, "the install said nothing of its own"); + + let_the_open_expire(); /* past the hold's deadline too */ + seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.fails, "the install did not end its own card"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, + "the hold outlived the install that took it over"); + + QActive_unsubscribeAll(ao_spy()); + (void)net_seen(); + fetch_end(); +} + +TEST_CASE("a terminal install takes the card over too", + "[appmgr_ao][appdata_get][hold]") +{ + uint32_t const len = image_named("snake"); + + card_held(); + + ask_recv_named(len, 0xDEADBEEFU, false, "snake"); + raw_armed(true); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, card_seen().dones, + "the card closed between the fetch and its install"); + + feed(s_named_img, (uint16_t)len); /* the write ends, CRC refused */ + run_ticks(APPMGR_ANSWER_DRAIN_DEADLINE_TICKS + 2U); + (void)card_seen(); + run_ticks(APPMGR_HOLD_TEST_TICKS + 10U); + card_seen_t const seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, + "the hold outlived the install that took it over"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.reports, + "the hold kept saying 0 % after the install took the card"); + QActive_unsubscribeAll(ao_spy()); + fetch_end(); +} + +TEST_CASE("the card a fetch kept ends when its install does not come", + "[appmgr_ao][appdata_get][hold]") +{ + card_held(); + + run_ticks(APPMGR_HOLD_TEST_TICKS - 50U); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, card_seen().dones, "the card closed early"); + + run_ticks(100U); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, card_seen().dones, + "a card waiting for an install that never comes stands for ever"); + run_ticks(3U * APPMGR_WAIT_BEAT_TICKS); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, card_seen().reports, + "the beat went on after the card ended"); + + QActive_unsubscribeAll(ao_spy()); + fetch_end(); +} + +TEST_CASE("an install refused before it begins ends the card a fetch kept", + "[appmgr_ao][appdata_get][hold]") +{ + card_held(); + + ask_recv(0U, 0U, false); /* no size: refused in idle */ + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, card_seen().dones, + "a refused install left the card waiting for it"); + run_ticks(APPMGR_HOLD_TEST_TICKS + 10U); + card_seen_t const seen = card_seen(); + TEST_ASSERT_EQUAL_UINT(0U, seen.reports); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, "the card was ended twice"); + + QActive_unsubscribeAll(ao_spy()); + fetch_end(); +} + +TEST_CASE("another request ends the card a fetch kept before it is taken", + "[appmgr_ao][appdata_get][hold]") +{ + card_held(); + + ask_get("ladder", GET_URL); /* another app's folder */ + card_seen_t const seen = card_seen(); + TEST_ASSERT_EQUAL_UINT(1U, seen.dones); + TEST_ASSERT_EQUAL_INT_MESSAGE(APP_FLASH_DONE_SIG, seen.first, + "the next card rose over the one before, which wore another app's face"); + TEST_ASSERT_EQUAL_STRING("ladder", seen.project); + + (void)net_seen(); + fetch_opened((uint8_t)KODE_ERROR, 0U); /* the second fetch ends */ + QActive_unsubscribeAll(ao_spy()); + ao_spy_drain(); + fetch_end(); +} + +TEST_CASE("a fetch that announces an install and fails leaves its card to the install", + "[appmgr_ao][appdata_get][hold]") +{ + fetch_fresh(); + card_listen(); + + ask_get_as("snake", GET_URL, true); + (void)net_seen(); + fetch_opened((uint8_t)KODE_ERROR, 0U); /* the server says no */ + card_seen_t seen = card_seen(); + TEST_ASSERT_EQUAL_UINT(1U, seen.answers); + TEST_ASSERT_EQUAL_INT_MESSAGE(APPDATA_GET_FETCH, seen.why, + "the fetch's own answer changed"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.fails, + "the app's data is not the install: its failure raised the failure sheet"); + TEST_ASSERT_EQUAL_UINT(0U, seen.dones); + + run_ticks(APPMGR_HOLD_TEST_TICKS + 10U); + seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.dones, "the deadline did not end the card"); + + QActive_unsubscribeAll(ao_spy()); + fetch_end(); +} + +TEST_CASE("the fetch's request carries whether an install follows", + "[appmgr_ao][appdata_get][hold]") +{ + fetch_fresh(); + TEST_ASSERT_EQUAL_INT(KODE_OK, + appmgr_appdata_get_request("snake", GET_URL, true, ao_spy())); + + const QEvt *e = NULL; + TEST_ASSERT_TRUE(pdTRUE == xQueueReceive(AO_AppMgr->eQueue, &e, 0U)); + TEST_ASSERT_EQUAL_INT(APPDATA_GET_REQ_SIG, (int)e->sig); + TEST_ASSERT_TRUE(((AppDataGetReqEvt const *)e)->install); + QF_gc(e); + fetch_end(); +} diff --git a/tests/host/cases/test_console_appdata.c b/tests/host/cases/test_console_appdata.c index 03fc331..a2c47bf 100644 --- a/tests/host/cases/test_console_appdata.c +++ b/tests/host/cases/test_console_appdata.c @@ -685,6 +685,50 @@ TEST_CASE("appdata_get needs both its keys", "[console_appdata]") TEST_ASSERT_FALSE(get_asked(&req)); } +TEST_CASE("appdata_get says an install follows only when it is told so", + "[console_appdata]") +{ + static const struct { + const char *tail; + bool install; + } CASES[] = { + { "", false }, + { " install=off", false }, + { " install=on", true }, + }; + static char line[KODE_CONSOLE_LINE_MAX]; + AppDataGetReqEvt req; + + for ( size_t i = 0U; (sizeof(CASES) / sizeof(CASES[0])) > i; ++i ) + { + console_up_once(); + (void)get_asked(&req); + (void)snprintf(line, sizeof(line), + "appdata_get name=\"snake\" url=\"http://example.invalid/s/\"%s", + CASES[i].tail); + + type_line(line); + + TEST_ASSERT_TRUE_MESSAGE(get_asked(&req), line); + TEST_ASSERT_EQUAL_MESSAGE(CASES[i].install, req.install, line); + get_answered("snake", APPDATA_GET_OK, false); + TEST_ASSERT_TRUE(replied(">ok")); + } +} + +TEST_CASE("appdata_get refuses an install that is neither on nor off", + "[console_appdata]") +{ + AppDataGetReqEvt req; + + console_up_once(); + (void)get_asked(&req); + + type_line("appdata_get name=\"snake\" url=\"http://example.invalid/s/\" install=yes"); + TEST_ASSERT_TRUE(replied(">err code=3 msg=install")); + TEST_ASSERT_FALSE_MESSAGE(get_asked(&req), "a fetch was asked for on a word it cannot read"); +} + TEST_CASE("an answer to a fetch nobody typed says nothing", "[console_appdata]") { console_up_once(); From cd90d7c068a6d2b81a5e74a0fe331a3458193dc2 Mon Sep 17 00:00:00 2001 From: jbenedictocenteno Date: Fri, 13 Nov 2026 16:47:37 +0100 Subject: [PATCH 2/7] fix(kaos): End a fetch's held card when a terminal install comes A terminal install took the card over like an install from a URL, with nothing published. It announces itself only at its ready, so one that ended before it - the console refused to arm, no plan, no room - owed the glass no failure: the card stayed up, locked, with nobody to end it. A terminal install now ends the held card like any other request, before anything it publishes, and raises its own card at its ready as it always did. Only an install from a medium or a URL takes the card over. --- components/kaos/appmgr/src/appmgr_ao.c | 20 ++++++---- components/kaos/core/include/kaos.h | 13 ++++--- tests/host/cases/test_appmgr_ao.c | 53 ++++++++++++++++++++------ 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/components/kaos/appmgr/src/appmgr_ao.c b/components/kaos/appmgr/src/appmgr_ao.c index 8dfefa6..7417c58 100644 --- a/components/kaos/appmgr/src/appmgr_ao.c +++ b/components/kaos/appmgr/src/appmgr_ao.c @@ -543,9 +543,10 @@ typedef struct { /* ---- The card a fetch kept up for the install it announced ----------- * Between `appdata_get install=on` and the install the host sends next, * the glass would empty and fill again: the fetch's card stays at 0 %, - * said again every second on `beat`, while AppMgr_idle waits. The - * install that begins takes the card over (hold_release()); anything - * else that idle turns to, or `hold` going off, ends it (hold_end()). */ + * said again every second on `beat`, while AppMgr_idle waits. An + * install from a medium or a URL that begins takes the card over + * (hold_release()); anything else that idle turns to, a terminal install + * included, or `hold` going off, ends it (hold_end()). */ QTimeEvt hold; /**< How long the card waits (APP_HOLD_SIG). */ bool held; /**< The card is up, waiting for an install. */ } AppMgr; @@ -3642,6 +3643,10 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) bool const to_pending = ((uint8_t)KODE_FLASH_DST_PENDING == req->dst); + /* A terminal install announces itself at its ready, not before: + * it raises its own card there, and one it took over would have + * nobody to end it if the wire were refused. */ + hold_end(me); if ( NULL == me->chunk ) { KLOG_E(TAG, "install failed: no chunk buffer (see the boot)"); @@ -4249,11 +4254,10 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) break; } } - /* The card a fetch kept up is the next install's, and only an install - * that begins takes it over; one refused before it began ends it here, - * where the refusal is known, rather than at the deadline. */ - if ( me->held && (((QSignal)FLASH_REQ_SIG == e->sig) - || ((QSignal)APP_RECV_REQ_SIG == e->sig)) ) + /* The card a fetch kept up is the next install's from a medium or a URL, + * and only one that begins takes it over; one refused before it began + * ends it here, where the refusal is known, rather than at the deadline. */ + if ( me->held && ((QSignal)FLASH_REQ_SIG == e->sig) ) { if ( Q_RET_TRAN == status ) { diff --git a/components/kaos/core/include/kaos.h b/components/kaos/core/include/kaos.h index 633174a..b777374 100644 --- a/components/kaos/core/include/kaos.h +++ b/components/kaos/core/include/kaos.h @@ -3906,11 +3906,14 @@ typedef struct { * * A fetch asked with @p install is the first step of an install, and its card * is the install's: whatever the fetch's end, neither event goes out. The card - * stays at 0 %, said again every second, until the next install request takes - * it over - its own reports carry it from there, so the glass never empties - * between the two - or until AO_AppMgr turns to anything else, a refused - * install included, or KODE_APPMGR_HOLD_MS goes by; then the unnamed - * APP_FLASH_DONE_SIG ends it. The answer to the fetch is the same either way. + * stays at 0 %, said again every second, until the next install from a medium + * or a URL takes it over - its own reports carry it from there, so the glass + * never empties between the two - or until AO_AppMgr turns to anything else, + * a refused install and a terminal install included, or KODE_APPMGR_HOLD_MS + * goes by; then the unnamed APP_FLASH_DONE_SIG ends it. A terminal install + * raises its own card at its ready. The reports of the install that took the + * card name no project until its image is read. The answer to the fetch is + * the same either way. * * @param project The app's project name, as its image declares it. * @param url The base URL, ending where the file names begin; shorter than diff --git a/tests/host/cases/test_appmgr_ao.c b/tests/host/cases/test_appmgr_ao.c index dcedd2d..b26fd6b 100644 --- a/tests/host/cases/test_appmgr_ao.c +++ b/tests/host/cases/test_appmgr_ao.c @@ -9903,9 +9903,9 @@ TEST_CASE("a fetch of an app's data folder shows from its first moment and ends /* ---- A fetch that announces an install -------------------------------------- * `appdata_get install=on`: the host sends the app's install the moment the * fetch is answered, and the glass must not empty between the two. The fetch's - * card stays at 0 % for the install; the install that begins takes it over, and - * anything else - a refusal, another request, the deadline - ends it with the - * unnamed done. */ + * card stays at 0 % for the install; an install from a medium or a URL that + * begins takes it over, and anything else - a refusal, a terminal install, + * another request, the deadline - ends it with the unnamed done. */ /** @brief KODE_APPMGR_HOLD_MS in QP ticks, as appmgr_ao.c has it. */ #define APPMGR_HOLD_TEST_TICKS (KODE_APPMGR_HOLD_MS / 10U) @@ -10045,7 +10045,7 @@ TEST_CASE("the install a fetch announced takes its card over without closing it" fetch_end(); } -TEST_CASE("a terminal install takes the card over too", +TEST_CASE("a terminal install ends the card a fetch kept and raises its own at its ready", "[appmgr_ao][appdata_get][hold]") { uint32_t const len = image_named("snake"); @@ -10053,19 +10053,50 @@ TEST_CASE("a terminal install takes the card over too", card_held(); ask_recv_named(len, 0xDEADBEEFU, false, "snake"); + card_seen_t seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.dones, + "a terminal install took the card a fetch kept, and owes it no end before its ready"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.reports, + "a terminal install announced itself before the wire was its"); + raw_armed(true); - TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, card_seen().dones, - "the card closed between the fetch and its install"); + seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.reports, + "the install's own card did not rise at its ready"); + TEST_ASSERT_EQUAL_UINT(0U, seen.dones); feed(s_named_img, (uint16_t)len); /* the write ends, CRC refused */ run_ticks(APPMGR_ANSWER_DRAIN_DEADLINE_TICKS + 2U); - (void)card_seen(); + seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.fails, "the install did not end its own card"); run_ticks(APPMGR_HOLD_TEST_TICKS + 10U); - card_seen_t const seen = card_seen(); - TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, - "the hold outlived the install that took it over"); + seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, "the card was ended twice"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.reports, + "the hold kept saying 0 % after the install came"); + QActive_unsubscribeAll(ao_spy()); + fetch_end(); +} + +TEST_CASE("a terminal install refused the wire leaves no card behind it", + "[appmgr_ao][appdata_get][hold]") +{ + uint32_t const len = image_named("snake"); + + card_held(); + + ask_recv_named(len, 0xDEADBEEFU, false, "snake"); + raw_armed(false); /* AO_Log refuses to arm */ + card_seen_t seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.dones, + "the card a fetch kept outlived an install that never took the wire"); + TEST_ASSERT_EQUAL_UINT(0U, seen.fails); + + run_ticks(APPMGR_HOLD_TEST_TICKS + 10U); + seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, "the card was ended twice"); TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.reports, - "the hold kept saying 0 % after the install took the card"); + "a card nobody will end is still being said"); QActive_unsubscribeAll(ao_spy()); fetch_end(); } From f8651e131d418394467dacce8a236035ddb83ac2 Mon Sep 17 00:00:00 2001 From: jbenedictocenteno Date: Fri, 13 Nov 2026 16:51:08 +0100 Subject: [PATCH 3/7] refactor(kaos): Count a held card's wait on the beat that says it The held card had a timer of its own beside the beat that says its 0 % every second, and a private signal for it. Idle already wakes on that beat while the card is up, so the beat counts the wait and the last one ends the card: one timer and APP_HOLD_SIG go, and AO_AppMgr's static gets back the 24 bytes the timer took. KODE_APPMGR_HOLD_MS stays the one place the wait is set, checked at build time to be a whole number of beats. --- components/kaos/appmgr/src/appmgr_ao.c | 49 ++++++++++++++------------ components/kqp/include/kqp_config.h | 3 +- components/kqp/include/kqp_signals.h | 4 --- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/components/kaos/appmgr/src/appmgr_ao.c b/components/kaos/appmgr/src/appmgr_ao.c index 7417c58..e0e651c 100644 --- a/components/kaos/appmgr/src/appmgr_ao.c +++ b/components/kaos/appmgr/src/appmgr_ao.c @@ -78,10 +78,15 @@ * open of a download, a file that stops arriving, a data folder being fetched. * AO_Power takes the LED's orange pulse back after three seconds without a * report, and the glass's write card has nothing to follow between two. */ -#define APPMGR_WAIT_BEAT_TICKS KQP_TICKS_PER_MS(1000U) -/* How long a fetch's card waits for the install the fetch announced - * (appmgr_appdata_get_request()'s `install`). */ -#define APPMGR_HOLD_TICKS KQP_TICKS_PER_MS(KODE_APPMGR_HOLD_MS) +#define APPMGR_WAIT_BEAT_MS 1000U +#define APPMGR_WAIT_BEAT_TICKS KQP_TICKS_PER_MS(APPMGR_WAIT_BEAT_MS) +/* How many of those beats a fetch's card waits for the install the fetch + * announced (appmgr_appdata_get_request()'s `install`): the beat that says the + * card again is the one that counts its time. */ +#define APPMGR_HOLD_BEATS (KODE_APPMGR_HOLD_MS / APPMGR_WAIT_BEAT_MS) +_Static_assert(((KODE_APPMGR_HOLD_MS % APPMGR_WAIT_BEAT_MS) == 0U) + && (0U < APPMGR_HOLD_BEATS) && (UINT8_MAX >= APPMGR_HOLD_BEATS), + "a held card's wait is a whole number of beats that fits its count"); /* How long the card may take over one copy before it is given up on: a base * that covers mounting and a slow counterfeit card, plus a second for every * 128 KB, so a 13 MB extent gets nearly three minutes and a small one a @@ -546,9 +551,10 @@ typedef struct { * said again every second on `beat`, while AppMgr_idle waits. An * install from a medium or a URL that begins takes the card over * (hold_release()); anything else that idle turns to, a terminal install - * included, or `hold` going off, ends it (hold_end()). */ - QTimeEvt hold; /**< How long the card waits (APP_HOLD_SIG). */ + * included, or APPMGR_HOLD_BEATS beats with no install, ends it + * (hold_end()). */ bool held; /**< The card is up, waiting for an install. */ + uint8_t hold_beats; /**< Beats the card has waited so far. */ } AppMgr; static AppMgr l_appMgr; @@ -3176,7 +3182,6 @@ static void beat_say(AppMgr * const me) static void hold_release(AppMgr * const me) { me->held = false; - (void)QTimeEvt_disarm(&me->hold); (void)QTimeEvt_disarm(&me->beat); } @@ -3378,7 +3383,6 @@ void AppMgr_ctor(void) QTimeEvt_ctorX(&me->pacer, &me->super, APP_FLASH_CHUNK_SIG, 0U); QTimeEvt_ctorX(&me->idle, &me->super, APP_RECV_IDLE_SIG, 0U); QTimeEvt_ctorX(&me->beat, &me->super, APP_BEAT_SIG, 0U); - QTimeEvt_ctorX(&me->hold, &me->super, APP_HOLD_SIG, 0U); QTimeEvt_ctorX(&me->undo_tmo, &me->super, APP_UNDO_STEP_SIG, 0U); QTimeEvt_ctorX(&me->answer, &me->super, APP_RECV_LAUNCH_SIG, 0U); QTimeEvt_ctorX(&me->tidy, &me->super, APP_TIDY_TIMEOUT_SIG, 0U); @@ -4203,7 +4207,7 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) if ( me->held ) { /* A fetch that announced an install ended on the way here. */ - (void)QTimeEvt_rearm(&me->hold, APPMGR_HOLD_TICKS); + me->hold_beats = 0U; (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); } status = Q_HANDLED(); @@ -4211,21 +4215,22 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) } case APP_BEAT_SIG: { /* Only the held card's: a beat queued behind another state's - * last disarm has nothing to say here. */ + * last disarm has nothing to say here. Each beat says the card + * again, and the last one of its wait ends it. */ if ( me->held ) { - progress_publish(me, 0U); - (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); - } - status = Q_HANDLED(); - break; - } - case APP_HOLD_SIG: { - if ( me->held ) - { - KLOG_W(TAG, "the install '%s' was fetched for did not come; " - "its card ends", me->project); - hold_end(me); + ++me->hold_beats; + if ( (uint8_t)APPMGR_HOLD_BEATS <= me->hold_beats ) + { + KLOG_W(TAG, "the install '%s' was fetched for did not come; " + "its card ends", me->project); + hold_end(me); + } + else + { + progress_publish(me, 0U); + (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); + } } status = Q_HANDLED(); break; diff --git a/components/kqp/include/kqp_config.h b/components/kqp/include/kqp_config.h index cb24b8d..446eb8f 100644 --- a/components/kqp/include/kqp_config.h +++ b/components/kqp/include/kqp_config.h @@ -1632,7 +1632,8 @@ _Static_assert(KODE_IR_TEST_BUDGET_MS > (KODE_IR_LINK_WAIT_MS + (KODE_IR_TEST_TR /** @brief The pause before each of those tries, in ms. */ #define KODE_APPMGR_PUT_RETRY_MS 500U /** @brief How long the card of a fetch that announced an install - * (`appdata_get install=on`) waits for that install to begin, in ms. + * (`appdata_get install=on`) waits for that install to begin, in ms: + * a whole number of seconds, counted on the beat that says the card. * The host sends it the moment the fetch is answered; this only bounds * a host that never does, so the card does not stand for ever. */ #define KODE_APPMGR_HOLD_MS 15000U diff --git a/components/kqp/include/kqp_signals.h b/components/kqp/include/kqp_signals.h index 8704c0e..31862d4 100644 --- a/components/kqp/include/kqp_signals.h +++ b/components/kqp/include/kqp_signals.h @@ -550,10 +550,6 @@ enum KodeSignals { * waiting on the network has gone by - * report the write's percent again, so the * glass and the LED stay up. */ - APP_HOLD_SIG, /**< AO_AppMgr private QTimeEvt: the install - * an `appdata_get install=on` announced - * did not begin in KODE_APPMGR_HOLD_MS - - * end the card the fetch kept up for it. */ FLASH_REQ_SIG, /**< AO_AppMgr: one source-to-sink transfer * (FlashReqEvt). The only way to ask for * an image to be written. */ From 96a5fe33b57fdc3cb1ca79d7de9425b143ec0d82 Mon Sep 17 00:00:00 2001 From: jbenedictocenteno Date: Fri, 13 Nov 2026 16:52:39 +0100 Subject: [PATCH 4/7] refactor(kaos): Give a held card's reason to the round without a ternary The house style spells a choice out with an if; the answer to a low-power round over a held card was the one new ternary in the file. --- components/kaos/appmgr/src/appmgr_ao.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/kaos/appmgr/src/appmgr_ao.c b/components/kaos/appmgr/src/appmgr_ao.c index e0e651c..b72f23b 100644 --- a/components/kaos/appmgr/src/appmgr_ao.c +++ b/components/kaos/appmgr/src/appmgr_ao.c @@ -4248,8 +4248,14 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) case LOWPOWER_ROUND_SIG: { /* Idle: no image is being written, kept, received or launched; * a card waiting for its install is the one thing in hand. */ + const char *why = NULL; + + if ( me->held ) + { + why = "install announced"; + } lowpower_ready(me->lp_who, Q_EVT_CAST(LowPowerRoundEvt)->round, - me->held, me->held ? "install announced" : NULL); + me->held, why); status = Q_HANDLED(); break; } From d2f06327a63c312390a3d4840c0b133f092ef111 Mon Sep 17 00:00:00 2001 From: jbenedictocenteno Date: Fri, 13 Nov 2026 17:21:34 +0100 Subject: [PATCH 5/7] docs(kqp): Say the beat also paces a card waiting for its install APP_BEAT_SIG was described as the second gone by while waiting on the network. It is also what says a held card again and counts its wait. --- components/kqp/include/kqp_signals.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/kqp/include/kqp_signals.h b/components/kqp/include/kqp_signals.h index 31862d4..3931b86 100644 --- a/components/kqp/include/kqp_signals.h +++ b/components/kqp/include/kqp_signals.h @@ -547,7 +547,8 @@ enum KodeSignals { * self, one per app, never by anything * external. */ APP_BEAT_SIG, /**< AO_AppMgr private QTimeEvt: a second of - * waiting on the network has gone by - + * waiting on the network, or for the + * install a fetch announced, has gone by - * report the write's percent again, so the * glass and the LED stay up. */ FLASH_REQ_SIG, /**< AO_AppMgr: one source-to-sink transfer From 0ee536560b644f694f393eb3e9ffb8fac66989fe Mon Sep 17 00:00:00 2001 From: jbenedictocenteno Date: Fri, 13 Nov 2026 17:21:34 +0100 Subject: [PATCH 6/7] test(kaos): Cover what ends a fetch's held card and what it answers No case asked a held card for its answer to a low-power round, nor sent it a run, an erase, a keep-only or an install from a medium refused in idle: each ends the card, and only another fetch was covered. The case of the take-over now also says what its first report names: nobody, until the image is read, which is what the interface keeps the fetch's face through. --- tests/host/cases/test_appmgr_ao.c | 93 +++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/host/cases/test_appmgr_ao.c b/tests/host/cases/test_appmgr_ao.c index b26fd6b..a43f6f9 100644 --- a/tests/host/cases/test_appmgr_ao.c +++ b/tests/host/cases/test_appmgr_ao.c @@ -10033,6 +10033,10 @@ TEST_CASE("the install a fetch announced takes its card over without closing it" TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, "the card closed between the fetch and its install"); TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.reports, "the install said nothing of its own"); + /* A URL is not its project: the card's reports name nobody until the + * image's first chunk is read, and the glass keeps the fetch's face. */ + TEST_ASSERT_EQUAL_STRING_MESSAGE("", seen.project, + "the install named an app before it read its image"); let_the_open_expire(); /* past the hold's deadline too */ seen = card_seen(); @@ -10181,6 +10185,95 @@ TEST_CASE("a fetch that announces an install and fails leaves its card to the in fetch_end(); } +/** @brief The held card has just been ended, once, by @p who, and stays ended. */ +static void card_expect_ended(const char *who) +{ + card_seen_t seen = card_seen(); + + TEST_ASSERT_EQUAL_UINT_MESSAGE(1U, seen.dones, who); + TEST_ASSERT_EQUAL_INT_MESSAGE(APP_FLASH_DONE_SIG, seen.first, who); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.fails, who); + run_ticks(APPMGR_HOLD_TEST_TICKS + 10U); + seen = card_seen(); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.dones, "the card was ended twice"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0U, seen.reports, "an ended card is still being said"); +} + +TEST_CASE("an install from a medium refused before it begins ends the card a fetch kept", + "[appmgr_ao][appdata_get][hold]") +{ + char long_name[KODE_FLASH_SRC_MAX]; + + (void)memset(long_name, 'n', sizeof(long_name) - 1U); + long_name[sizeof(long_name) - 1U] = '\0'; + + card_held(); + + /* Longer than a file's name may be: refused in idle, with no failure. */ + ask_install_from((uint8_t)KFW_SRC_FS, long_name, (uint8_t)KFW_TARGET_APP); + card_expect_ended("a refused install left the card waiting for it"); + + QActive_unsubscribeAll(ao_spy()); + ao_spy_drain(); + fetch_end(); +} + +TEST_CASE("a run ends the card a fetch kept", "[appmgr_ao][appdata_get][hold]") +{ + card_held(); + + ask_run_from("ghost"); /* not installed: answered in idle */ + card_expect_ended("a run left the card of an install that is not it"); + + QActive_unsubscribeAll(ao_spy()); + ao_spy_drain(); + fetch_end(); +} + +TEST_CASE("an erase ends the card a fetch kept", "[appmgr_ao][appdata_get][hold]") +{ + card_held(); + + ask_erase(""); /* no name: answered in idle */ + card_expect_ended("an erase left the card of an install that is not it"); + + QActive_unsubscribeAll(ao_spy()); + ao_spy_drain(); + fetch_end(); +} + +TEST_CASE("a keep-only ends the card a fetch kept", "[appmgr_ao][appdata_get][hold]") +{ + card_held(); + + TEST_ASSERT_TRUE(appmgr_keep_only_request("ghost", ao_spy())); + (void)qf_support_pump(AO_AppMgr, 8U); /* not installed: answered in idle */ + card_expect_ended("a keep-only left the card of an install that is not it"); + + QActive_unsubscribeAll(ao_spy()); + ao_spy_drain(); + fetch_end(); +} + +TEST_CASE("a held card answers a round busy, and an ended one is ready", + "[appmgr_ao][appdata_get][hold][lowpower]") +{ + card_held(); + + lp_round(AO_AppMgr, KODE_LP_ASLEEP); + lp_expect_answer(true, "install announced"); + lp_expect_no_more(); + + run_ticks(APPMGR_HOLD_TEST_TICKS + 10U); /* the install never came */ + lp_round(AO_AppMgr, KODE_LP_ASLEEP); + lp_expect_answer(false, NULL); + lp_expect_no_more(); + + QActive_unsubscribeAll(ao_spy()); + ao_spy_drain(); + fetch_end(); +} + TEST_CASE("the fetch's request carries whether an install follows", "[appmgr_ao][appdata_get][hold]") { From c5c47d9f0fe2fb7eb59ed960cfa1ffe1850c66a3 Mon Sep 17 00:00:00 2001 From: jbenedictocenteno Date: Fri, 13 Nov 2026 17:37:05 +0100 Subject: [PATCH 7/7] refactor(kaos): Keep a held card's wait as one count of beats left A held card had a flag and a count beside it, and with the fetch's own flag they took AO_AppMgr's static four bytes past what it measures on develop, in a struct that must stay under 2048 bytes of internal RAM. The count now runs down, and a count of zero is no card held: one field where there were two. The fetch's flag sits in a hole ahead of the fetch's other fields. The struct measures what it does without the hold. --- components/kaos/appmgr/src/appmgr_ao.c | 52 +++++++++++++------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/components/kaos/appmgr/src/appmgr_ao.c b/components/kaos/appmgr/src/appmgr_ao.c index adf7d5a..81dc9cd 100644 --- a/components/kaos/appmgr/src/appmgr_ao.c +++ b/components/kaos/appmgr/src/appmgr_ao.c @@ -564,6 +564,7 @@ typedef struct { * AO_Storage is handed a copy of it for each try. * The base URL is kept in PSRAM too, for the icon's open (rule 8: 200 * bytes of internal RAM held for a fetch that rarely happens). */ + bool get_install; /**< An install follows: the card is its. */ QActive *get_who; /**< Who asked. */ char get_project[KODE_APP_PROJECT_MAX]; /**< Whose folder. */ char *get_url; /**< The base URL, KODE_APPDATA_URL_MAX of @@ -577,7 +578,6 @@ typedef struct { uint8_t get_tries; /**< Times the put was asked again after a * busy refusal. */ bool get_open; /**< A transfer is open with AO_Net. */ - bool get_install; /**< An install follows: the card is its. */ /* ---- The card a fetch kept up for the install it announced ----------- * Between `appdata_get install=on` and the install the host sends next, @@ -587,8 +587,8 @@ typedef struct { * (hold_release()); anything else that idle turns to, a terminal install * included, or APPMGR_HOLD_BEATS beats with no install, ends it * (hold_end()). */ - bool held; /**< The card is up, waiting for an install. */ - uint8_t hold_beats; /**< Beats the card has waited so far. */ + uint8_t hold_left; /**< Beats the held card still waits; 0 when + * no card is held. */ } AppMgr; static AppMgr l_appMgr; @@ -3345,7 +3345,7 @@ static void beat_say(AppMgr * const me) */ static void hold_release(AppMgr * const me) { - me->held = false; + me->hold_left = 0U; (void)QTimeEvt_disarm(&me->beat); } @@ -3358,7 +3358,7 @@ static void hold_release(AppMgr * const me) */ static void hold_end(AppMgr * const me) { - if ( me->held ) + if ( 0U < me->hold_left ) { hold_release(me); QACTIVE_PUBLISH(&s_flash_done_evt.super, &me->super); @@ -4369,10 +4369,9 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) break; } case Q_ENTRY_SIG: { - if ( me->held ) + if ( 0U < me->hold_left ) { /* A fetch that announced an install ended on the way here. */ - me->hold_beats = 0U; (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); } status = Q_HANDLED(); @@ -4382,20 +4381,21 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) /* Only the held card's: a beat queued behind another state's * last disarm has nothing to say here. Each beat says the card * again, and the last one of its wait ends it. */ - if ( me->held ) + if ( 1U == me->hold_left ) { - ++me->hold_beats; - if ( (uint8_t)APPMGR_HOLD_BEATS <= me->hold_beats ) - { - KLOG_W(TAG, "the install '%s' was fetched for did not come; " - "its card ends", me->project); - hold_end(me); - } - else - { - progress_publish(me, 0U); - (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); - } + KLOG_W(TAG, "the install '%s' was fetched for did not come; " + "its card ends", me->project); + hold_end(me); + } + else if ( 1U < me->hold_left ) + { + --me->hold_left; + progress_publish(me, 0U); + (void)QTimeEvt_rearm(&me->beat, APPMGR_WAIT_BEAT_TICKS); + } + else + { + /* No card is held. */ } status = Q_HANDLED(); break; @@ -4413,14 +4413,15 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) case LOWPOWER_ROUND_SIG: { /* Idle: no image is being written, kept, received or launched; * a card waiting for its install is the one thing in hand. */ - const char *why = NULL; + bool const held = (0U < me->hold_left); + const char *why = NULL; - if ( me->held ) + if ( held ) { why = "install announced"; } lowpower_ready(me->lp_who, Q_EVT_CAST(LowPowerRoundEvt)->round, - me->held, why); + held, why); status = Q_HANDLED(); break; } @@ -4433,7 +4434,7 @@ static QState AppMgr_idle(AppMgr * const me, QEvt const * const e) /* The card a fetch kept up is the next install's from a medium or a URL, * and only one that begins takes it over; one refused before it began * ends it here, where the refusal is known, rather than at the deadline. */ - if ( me->held && ((QSignal)FLASH_REQ_SIG == e->sig) ) + if ( (0U < me->hold_left) && ((QSignal)FLASH_REQ_SIG == e->sig) ) { if ( Q_RET_TRAN == status ) { @@ -5887,7 +5888,8 @@ static void get_card_end(AppMgr * const me, bool ok) if ( me->get_install ) { me->get_install = false; - me->held = true; /* AppMgr_idle's entry arms its timers */ + /* AppMgr_idle's entry arms the beat that counts it down. */ + me->hold_left = (uint8_t)APPMGR_HOLD_BEATS; KLOG_N(TAG, "the card of '%s' waits for its install", me->get_project); } else if ( ok )