@mikrojs/firmware 0.19.0 → 0.20.0-next.20260904225731
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/mikrojs/CMakeLists.txt +2 -1
- package/components/mikrojs/mik_deploy.cpp +10 -0
- package/components/mikrojs/mik_main.cpp +59 -1
- package/components/mikrojs/mik_ota.cpp +18 -1
- package/components/mikrojs/mik_ota_client_module.cpp +102 -67
- package/components/mikrojs/mik_sleep.cpp +7 -0
- package/components/mikrojs/platform_esp32.cpp +21 -0
- package/components/mikrojs/test/CMakeLists.txt +1 -0
- package/components/mikrojs/test/watchdog_test.cpp +280 -0
- package/package.json +3 -3
- package/prebuilds/esp32/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32/mikrojs.bin +0 -0
- package/prebuilds/esp32c3/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c3/mikrojs.bin +0 -0
- package/prebuilds/esp32c5/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c5/mikrojs.bin +0 -0
- package/prebuilds/esp32c6/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c6/mikrojs.bin +0 -0
- package/prebuilds/esp32s3/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32s3/mikrojs.bin +0 -0
- package/sdkconfig.defaults +10 -0
|
@@ -75,6 +75,7 @@ idf_component_register(
|
|
|
75
75
|
"${MIK_SRC_DIR}/mik_sys_codec.cpp"
|
|
76
76
|
"${MIK_SRC_DIR}/mik_result.cpp"
|
|
77
77
|
"${MIK_SRC_DIR}/mik_udp.cpp"
|
|
78
|
+
"${MIK_SRC_DIR}/mik_watchdog.cpp"
|
|
78
79
|
"${MIK_SRC_DIR}/mik_observable.cpp"
|
|
79
80
|
"${MIK_SRC_DIR}/mikrojs.cpp"
|
|
80
81
|
# nanocbor CBOR codec
|
|
@@ -196,7 +197,7 @@ include("${MIK_BYTECODE_CMAKE}")
|
|
|
196
197
|
# Force linker to include self-registering native modules
|
|
197
198
|
set(_MIK_MODULES cbor pin i2c i2s spi http http_server wifi rtc nvs_kv sntp sleep neopixel pwm uart ota)
|
|
198
199
|
list(APPEND _MIK_MODULES ota_client)
|
|
199
|
-
set(_MIK_BYTECODE_MODULES abort cbor env result schema fs http/server i2c i2s kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client ota/config pin pwm reader sleep spi sntp stdio stream sys test uart udp)
|
|
200
|
+
set(_MIK_BYTECODE_MODULES abort cbor env result schema fs http/server i2c i2s kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client ota/config pin pwm reader sleep spi sntp stdio stream sys test uart udp watchdog)
|
|
200
201
|
if(CONFIG_BT_ENABLED)
|
|
201
202
|
list(APPEND _MIK_MODULES ble)
|
|
202
203
|
list(APPEND _MIK_BYTECODE_MODULES ble)
|
|
@@ -162,6 +162,13 @@ static bool manifest_matches(const ChecksumsManifest& m, const char* name, uint1
|
|
|
162
162
|
|
|
163
163
|
/* ── Filesystem helpers ──────────────────────────────────────────── */
|
|
164
164
|
|
|
165
|
+
/* Deploy handlers run on the main task without returning to MIK_Loop, so
|
|
166
|
+
* loops that touch flash per iteration feed the task watchdog themselves. */
|
|
167
|
+
static void feed_watchdog(void) {
|
|
168
|
+
const MIKPlatform* platform = MIK_GetPlatform();
|
|
169
|
+
if (platform->feed_watchdog) platform->feed_watchdog();
|
|
170
|
+
}
|
|
171
|
+
|
|
165
172
|
static void mkdirs(const char* path) {
|
|
166
173
|
char tmp[512];
|
|
167
174
|
snprintf(tmp, sizeof(tmp), "%s", path);
|
|
@@ -182,6 +189,7 @@ static void rmdir_recursive(const char* path) {
|
|
|
182
189
|
struct dirent* entry;
|
|
183
190
|
while ((entry = readdir(dir)) != NULL) {
|
|
184
191
|
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
|
|
192
|
+
feed_watchdog();
|
|
185
193
|
|
|
186
194
|
char full[512];
|
|
187
195
|
snprintf(full, sizeof(full), "%s/%s", path, entry->d_name);
|
|
@@ -221,6 +229,7 @@ static bool copy_file(const char* src, const char* dst) {
|
|
|
221
229
|
bool ok = true;
|
|
222
230
|
int saved_errno = 0;
|
|
223
231
|
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
|
|
232
|
+
feed_watchdog();
|
|
224
233
|
if (fwrite(buf, 1, n, out) != n) {
|
|
225
234
|
saved_errno = errno;
|
|
226
235
|
ok = false;
|
|
@@ -490,6 +499,7 @@ bool mik__handle_deploy_command(MIKReplTransport* transport, uint8_t cmd_type,
|
|
|
490
499
|
while (remaining > 0) {
|
|
491
500
|
uint32_t chunk = remaining > sizeof(buf) ? sizeof(buf) : remaining;
|
|
492
501
|
if (!mik__proto_read_exact(transport, buf, chunk)) return false;
|
|
502
|
+
feed_watchdog();
|
|
493
503
|
if (fwrite(buf, 1, chunk, s_put_file) != chunk) {
|
|
494
504
|
/* Write failure: keep the protocol in sync by draining
|
|
495
505
|
* the rest of this chunk's bytes, then abort the PUT. */
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
#include "esp_littlefs.h"
|
|
14
14
|
#include "esp_log.h"
|
|
15
15
|
#include "esp_system.h"
|
|
16
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
17
|
+
#include "esp_task_wdt.h"
|
|
18
|
+
#endif
|
|
16
19
|
#include "freertos/FreeRTOS.h"
|
|
17
20
|
#include "freertos/task.h"
|
|
18
21
|
#include "mikrojs.h"
|
|
@@ -22,6 +25,20 @@
|
|
|
22
25
|
|
|
23
26
|
static const char* TAG = "mikrojs";
|
|
24
27
|
|
|
28
|
+
#if CONFIG_ESP_TASK_WDT_INIT
|
|
29
|
+
/* Ordering invariant: the JS blocking watchdog interrupts (with a stack trace)
|
|
30
|
+
* before the hardware TWDT resets the chip. The margin covers unwinding and
|
|
31
|
+
* printing the trace. onPanic.delay is not part of the sum: MIK_Loop feeds
|
|
32
|
+
* the TWDT through the grace window. CONFIG_ESP_TASK_WDT_TIMEOUT_S only
|
|
33
|
+
* exists under CONFIG_ESP_TASK_WDT_INIT. */
|
|
34
|
+
#define MIK_TWDT_MARGIN_S 5
|
|
35
|
+
static_assert(MIK_WATCHDOG_BLOCKING_DEFAULT_MS / 1000 + MIK_TWDT_MARGIN_S <
|
|
36
|
+
CONFIG_ESP_TASK_WDT_TIMEOUT_S,
|
|
37
|
+
"watchdog.blocking default (30 s) must fire before the task watchdog: set "
|
|
38
|
+
"CONFIG_ESP_TASK_WDT_TIMEOUT_S=60 in sdkconfig, or delete sdkconfig and re-run "
|
|
39
|
+
"`idf.py set-target` so sdkconfig.defaults applies");
|
|
40
|
+
#endif
|
|
41
|
+
|
|
25
42
|
/* Error handler armed during a normal boot: when the app hits a fatal JS error
|
|
26
43
|
* (uncaught exception or unhandled rejection) while running an OTA trial build,
|
|
27
44
|
* flag the trial so the next reconcile reverts it. A JS error reboots as a clean
|
|
@@ -188,6 +205,9 @@ static bool platform_command_handler(MIKReplTransport* transport, uint8_t cmd_ty
|
|
|
188
205
|
/* Restart: drain payload (should be 0), then restart */
|
|
189
206
|
if (cmd_type == MIK_CMD_RESTART) {
|
|
190
207
|
mik__proto_drain(transport, payload_len);
|
|
208
|
+
/* Say why: a silent reset is indistinguishable from a crash. */
|
|
209
|
+
printf("[mikrojs] restart requested by host\n");
|
|
210
|
+
fflush(stdout);
|
|
191
211
|
esp_restart();
|
|
192
212
|
return true; /* unreachable */
|
|
193
213
|
}
|
|
@@ -261,7 +281,15 @@ static esp_err_t mount_littlefs(const char* base_path, const char* partition_lab
|
|
|
261
281
|
conf.partition_label = partition_label;
|
|
262
282
|
conf.format_if_mount_failed = true;
|
|
263
283
|
|
|
284
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
285
|
+
/* A first-boot format runs for seconds inside this one call, where no
|
|
286
|
+
* feed can reach; unsubscribe the main task for its duration. */
|
|
287
|
+
esp_task_wdt_delete(NULL);
|
|
288
|
+
#endif
|
|
264
289
|
esp_err_t ret = esp_vfs_littlefs_register(&conf);
|
|
290
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
291
|
+
esp_task_wdt_add(NULL);
|
|
292
|
+
#endif
|
|
265
293
|
if (ret != ESP_OK) {
|
|
266
294
|
ESP_LOGE(TAG, "Failed to mount LittleFS partition '%s' at '%s': %s", partition_label,
|
|
267
295
|
base_path, esp_err_to_name(ret));
|
|
@@ -328,6 +356,17 @@ void MIK_Main(void) {
|
|
|
328
356
|
printf("\n*** SAFE MODE: autorun skipped, dropping to REPL ***\n\n");
|
|
329
357
|
}
|
|
330
358
|
|
|
359
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
360
|
+
/* Watch the main task: a hang in native code below JS now resets the
|
|
361
|
+
* chip instead of sitting forever. Fed from MIK_Loop and platform->yield. */
|
|
362
|
+
{
|
|
363
|
+
esp_err_t err = esp_task_wdt_add(NULL);
|
|
364
|
+
if (err != ESP_OK) {
|
|
365
|
+
ESP_LOGW(TAG, "Could not add the main task to the task watchdog: %s", esp_err_to_name(err));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
#endif
|
|
369
|
+
|
|
331
370
|
/* Initialize NVS (needed for env vars, secrets, and WiFi) */
|
|
332
371
|
{
|
|
333
372
|
esp_err_t err = nvs_flash_init();
|
|
@@ -371,6 +410,22 @@ void MIK_Main(void) {
|
|
|
371
410
|
MIKConfig app_config;
|
|
372
411
|
MIK_LoadConfig("/appfs", &app_config);
|
|
373
412
|
|
|
413
|
+
#if CONFIG_ESP_TASK_WDT_INIT
|
|
414
|
+
/* Same ordering invariant as the static_assert above, for a configured
|
|
415
|
+
* value. Clamp and warn rather than fail, as the memReserved clamp does. */
|
|
416
|
+
{
|
|
417
|
+
constexpr int kMaxBlockingMs = (CONFIG_ESP_TASK_WDT_TIMEOUT_S - MIK_TWDT_MARGIN_S) * 1000;
|
|
418
|
+
if (app_config.blocking_timeout_ms > kMaxBlockingMs) {
|
|
419
|
+
ESP_LOGW(TAG,
|
|
420
|
+
"watchdog.blocking %d ms must stay %d s under the %d s task watchdog; "
|
|
421
|
+
"clamping to %d ms",
|
|
422
|
+
app_config.blocking_timeout_ms, MIK_TWDT_MARGIN_S,
|
|
423
|
+
CONFIG_ESP_TASK_WDT_TIMEOUT_S, kMaxBlockingMs);
|
|
424
|
+
app_config.blocking_timeout_ms = kMaxBlockingMs;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
#endif
|
|
428
|
+
|
|
374
429
|
/* Start file logging if configured (no-op when log_file is empty). */
|
|
375
430
|
mik_logfile_init(&app_config);
|
|
376
431
|
|
|
@@ -710,6 +765,9 @@ void MIK_Main(void) {
|
|
|
710
765
|
MIK_ProtocolClose();
|
|
711
766
|
MIK_FreeTests(test_paths, test_count);
|
|
712
767
|
|
|
713
|
-
/* If we get here, the transport died. Restart
|
|
768
|
+
/* If we get here, the transport died. Restart, and say so: this reset
|
|
769
|
+
* reads as "software" on the next boot, same as a host restart. */
|
|
770
|
+
printf("[mikrojs] console transport closed, restarting\n");
|
|
771
|
+
fflush(stdout);
|
|
714
772
|
esp_restart();
|
|
715
773
|
}
|
|
@@ -57,6 +57,18 @@ constexpr size_t kTarBlock = 512;
|
|
|
57
57
|
constexpr size_t kTarName = 100; // ustar name field width
|
|
58
58
|
constexpr int kMaxNameDepth = 8; // path components a member may create
|
|
59
59
|
|
|
60
|
+
// The boot install and the deploy-time verify run on the main task with no
|
|
61
|
+
// MIK_Loop pass in between, so flash-bound loops feed the task watchdog here.
|
|
62
|
+
// The host test has no platform and nothing to feed.
|
|
63
|
+
#ifdef MIK_OTA_HOST_TEST
|
|
64
|
+
void feed_watchdog(void) {}
|
|
65
|
+
#else
|
|
66
|
+
void feed_watchdog(void) {
|
|
67
|
+
const MIKPlatform* platform = MIK_GetPlatform();
|
|
68
|
+
if (platform->feed_watchdog) platform->feed_watchdog();
|
|
69
|
+
}
|
|
70
|
+
#endif
|
|
71
|
+
|
|
60
72
|
// Classified failure cause carried back to JS. `corrupt` means the bytes are
|
|
61
73
|
// deterministically bad (gzip/tar/SHA) so retrying identical bytes can't help;
|
|
62
74
|
// `transient` is fs/mount/io; `oom` is an allocation failure.
|
|
@@ -201,7 +213,10 @@ bool sha256_file(const char* path, char out[65]) {
|
|
|
201
213
|
Sha256 c;
|
|
202
214
|
sha256_init(&c);
|
|
203
215
|
size_t n;
|
|
204
|
-
while ((n = fread(buf, 1, kChunk, f)) > 0)
|
|
216
|
+
while ((n = fread(buf, 1, kChunk, f)) > 0) {
|
|
217
|
+
feed_watchdog();
|
|
218
|
+
sha256_update(&c, buf, n);
|
|
219
|
+
}
|
|
205
220
|
bool read_err = ferror(f) != 0;
|
|
206
221
|
free(buf);
|
|
207
222
|
fclose(f);
|
|
@@ -564,6 +579,7 @@ bool unpack_tgz(const char* in_path, const char* dest_dir, const char** err, Ota
|
|
|
564
579
|
unsigned char tail[8]; // rolling last-8-bytes of the file == the gzip trailer
|
|
565
580
|
size_t tail_len = 0;
|
|
566
581
|
for (;;) {
|
|
582
|
+
feed_watchdog();
|
|
567
583
|
if (avail_in == 0 && !eof) {
|
|
568
584
|
size_t in_n = fread(inbuf, 1, kChunk, in);
|
|
569
585
|
// A read error also returns 0. Distinguish it from real EOF: treating
|
|
@@ -834,6 +850,7 @@ void rmtree_at(char* path, size_t cap, int depth) {
|
|
|
834
850
|
dirent* ent;
|
|
835
851
|
while ((ent = readdir(d)) != nullptr) {
|
|
836
852
|
if (!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) continue;
|
|
853
|
+
feed_watchdog();
|
|
837
854
|
const int n = snprintf(path + base, cap - base, "/%s", ent->d_name);
|
|
838
855
|
if (n < 0 || (size_t)n >= cap - base) {
|
|
839
856
|
path[base] = 0; // would truncate to a different path; skip it
|
|
@@ -32,6 +32,7 @@ using mikrojs::MIKOtaConfigWrite;
|
|
|
32
32
|
using mikrojs::MIKOtaJsHooks;
|
|
33
33
|
using mikrojs::MIKOtaApplyOutcome;
|
|
34
34
|
using mikrojs::MIKOtaApplySession;
|
|
35
|
+
using mikrojs::MIKOtaDeclineRecord;
|
|
35
36
|
using mikrojs::MIKOtaError;
|
|
36
37
|
using mikrojs::MIKOtaInstallOptions;
|
|
37
38
|
using mikrojs::MIKOtaOffer;
|
|
@@ -381,7 +382,7 @@ JSValue ota_reconcile(JSContext* ctx, JSValue, int, JSValue*) {
|
|
|
381
382
|
return obj;
|
|
382
383
|
}
|
|
383
384
|
|
|
384
|
-
/* running
|
|
385
|
+
/* The running-build object, { checksum?, version?, trial }, for report(). */
|
|
385
386
|
JSValue ota_running(JSContext* ctx, JSValue, int, JSValue*) {
|
|
386
387
|
MIKOtaClientState* state = state_of(ctx);
|
|
387
388
|
CHECK_NOT_NULL(state);
|
|
@@ -431,20 +432,6 @@ JSValue offer_to_js(JSContext* ctx, const MIKOtaOffer& offer) {
|
|
|
431
432
|
return obj;
|
|
432
433
|
}
|
|
433
434
|
|
|
434
|
-
/* parseOffer(raw, {allowInsecure}) -> Offer | undefined */
|
|
435
|
-
JSValue ota_parse_offer(JSContext* ctx, JSValue, int argc, JSValue* argv) {
|
|
436
|
-
bool allow_insecure = false;
|
|
437
|
-
if (argc > 1 && JS_IsObject(argv[1])) {
|
|
438
|
-
allow_insecure = opt_bool(ctx, argv[1], "allowInsecure", false);
|
|
439
|
-
}
|
|
440
|
-
MIKOtaOffer offer;
|
|
441
|
-
if (!mikrojs::mik__ota_parse_offer_js(ctx, argc > 0 ? argv[0] : JS_UNDEFINED, allow_insecure,
|
|
442
|
-
&offer)) {
|
|
443
|
-
return JS_UNDEFINED;
|
|
444
|
-
}
|
|
445
|
-
return offer_to_js(ctx, offer);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
435
|
// ── applyOffer ──────────────────────────────────────────────────────────────
|
|
449
436
|
|
|
450
437
|
/* The staging session, as the download callback sees it. Recovered from the
|
|
@@ -504,15 +491,47 @@ JSValue make_update_object(JSContext* ctx, size_t resume_offset) {
|
|
|
504
491
|
return update;
|
|
505
492
|
}
|
|
506
493
|
|
|
494
|
+
/* True for the checksum shape the registry's checkin validation accepts. A
|
|
495
|
+
* lastDecline with any other checksum would 400 every future check-in — and
|
|
496
|
+
* permanently, since a failed round never settles and so never clears it. */
|
|
497
|
+
bool is_wire_checksum(const char* s) {
|
|
498
|
+
for (int i = 0; i < 64; i++) {
|
|
499
|
+
char c = s[i];
|
|
500
|
+
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return false;
|
|
501
|
+
}
|
|
502
|
+
return s[64] == '\0';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/* Record why an offered build was not taken, for the next report() to carry as
|
|
506
|
+
* lastDecline and a later settle() to mark delivered — the own-transport twin
|
|
507
|
+
* of the built-in client's NoteDecline, persisted because here the decline
|
|
508
|
+
* lands after the wake's check-in and must survive a deep sleep to make the
|
|
509
|
+
* next one. */
|
|
510
|
+
void note_decline(const MIKOtaEnv* env, const std::string& checksum, const char* reason,
|
|
511
|
+
const std::string& detail) {
|
|
512
|
+
if (!is_wire_checksum(checksum.c_str())) return;
|
|
513
|
+
MIKOtaDeclineRecord record;
|
|
514
|
+
snprintf(record.checksum, sizeof(record.checksum), "%s", checksum.c_str());
|
|
515
|
+
snprintf(record.reason, sizeof(record.reason), "%s", reason);
|
|
516
|
+
snprintf(record.detail, sizeof(record.detail), "%s", detail.c_str());
|
|
517
|
+
mikrojs::MIKOtaStore(env).SetDecline(record);
|
|
518
|
+
}
|
|
519
|
+
|
|
507
520
|
/* Close the attempt and settle applyOffer's promise. */
|
|
508
521
|
void settle_apply(JSContext* ctx, bool downloaded, const std::string& message) {
|
|
509
522
|
MIKOtaClientState* state = state_of(ctx);
|
|
510
523
|
if (!state || !state->apply.active) return;
|
|
511
524
|
|
|
525
|
+
std::string checksum = state->apply.session.checksum;
|
|
512
526
|
MIKOtaApplyOutcome outcome = MIKOtaApplyOutcome::kStaged;
|
|
513
527
|
MIKOtaError error;
|
|
514
528
|
bool ok = mikrojs::mik__ota_policy_apply_end(&state->apply.session, downloaded, message,
|
|
515
529
|
state->apply.options, &outcome, &error);
|
|
530
|
+
if (!ok) {
|
|
531
|
+
note_decline(state->env, checksum,
|
|
532
|
+
error.name == "DownloadFailed" ? "download-failed" : "install-failed",
|
|
533
|
+
error.message);
|
|
534
|
+
}
|
|
516
535
|
JSValue result = ok ? mik__result_ok(ctx, JS_NewString(ctx, mikrojs::mik__ota_outcome_to_str(
|
|
517
536
|
outcome)))
|
|
518
537
|
: mik__result_err_obj(ctx, error_to_js(ctx, error));
|
|
@@ -592,11 +611,18 @@ JSValue ota_apply_offer(JSContext* ctx, JSValue, int argc, JSValue* argv) {
|
|
|
592
611
|
MIKOtaError error;
|
|
593
612
|
if (!mikrojs::mik__ota_policy_apply_begin(state->env, offer, &state->apply.session, &outcome,
|
|
594
613
|
&error)) {
|
|
614
|
+
note_decline(state->env, offer.checksum, "install-failed", error.message);
|
|
595
615
|
JSValue failed = mik__result_err_obj(ctx, error_to_js(ctx, error));
|
|
596
616
|
return MIK_NewResolvedPromise(ctx, 1, &failed);
|
|
597
617
|
}
|
|
598
618
|
if (!state->apply.session.update) {
|
|
599
|
-
/* Declined before staging: trial pending, current, abandoned, exhausted.
|
|
619
|
+
/* Declined before staging: trial pending, current, abandoned, exhausted.
|
|
620
|
+
* Only the last two are recorded for the registry — the first two are
|
|
621
|
+
* the policy working as intended, mirroring the built-in's NoteDecline. */
|
|
622
|
+
if (outcome == MIKOtaApplyOutcome::kAbandoned ||
|
|
623
|
+
outcome == MIKOtaApplyOutcome::kExhausted) {
|
|
624
|
+
note_decline(state->env, offer.checksum, mikrojs::mik__ota_outcome_to_str(outcome), "");
|
|
625
|
+
}
|
|
600
626
|
JSValue declined =
|
|
601
627
|
mik__result_ok(ctx, JS_NewString(ctx, mikrojs::mik__ota_outcome_to_str(outcome)));
|
|
602
628
|
return MIK_NewResolvedPromise(ctx, 1, &declined);
|
|
@@ -742,25 +768,7 @@ bool encode_config_doc(JSContext* ctx, JSValue doc, std::vector<uint8_t>* out) {
|
|
|
742
768
|
return ok;
|
|
743
769
|
}
|
|
744
770
|
|
|
745
|
-
/*
|
|
746
|
-
JSValue ota_parse_config(JSContext* ctx, JSValue, int argc, JSValue* argv) {
|
|
747
|
-
MIKOtaStoredConfig cfg = {};
|
|
748
|
-
JSValue doc = JS_UNDEFINED;
|
|
749
|
-
if (argc < 1 || !config_from_js(ctx, argv[0], &cfg, &doc)) {
|
|
750
|
-
JS_FreeValue(ctx, doc);
|
|
751
|
-
return JS_UNDEFINED;
|
|
752
|
-
}
|
|
753
|
-
JSValue obj = JS_NewObject(ctx);
|
|
754
|
-
if (cfg.rev[0]) JS_SetPropertyStr(ctx, obj, "rev", JS_NewString(ctx, cfg.rev));
|
|
755
|
-
JS_SetPropertyStr(ctx, obj, "version", JS_NewString(ctx, cfg.version));
|
|
756
|
-
if (JS_IsUndefined(doc)) return obj;
|
|
757
|
-
/* The document travels on as it arrived: whether it survives CBOR is
|
|
758
|
-
* settled by applyConfig, which is where the bytes are actually made. */
|
|
759
|
-
JS_SetPropertyStr(ctx, obj, "doc", doc);
|
|
760
|
-
return obj;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/* applyConfig(cfg, {trialBoots}) -> ConfigWrite */
|
|
771
|
+
/* The config delivery, cfg + {trialBoots} -> ConfigWrite string, for settle(). */
|
|
764
772
|
JSValue ota_apply_config(JSContext* ctx, JSValue, int argc, JSValue* argv) {
|
|
765
773
|
MIKOtaClientState* state = state_of(ctx);
|
|
766
774
|
CHECK_NOT_NULL(state);
|
|
@@ -809,22 +817,6 @@ bool set_config_echo(JSContext* ctx, JSValue obj, const char* rev_key, const MIK
|
|
|
809
817
|
return has_error;
|
|
810
818
|
}
|
|
811
819
|
|
|
812
|
-
/* configState() -> {rev?, error?} */
|
|
813
|
-
JSValue ota_config_state(JSContext* ctx, JSValue, int, JSValue*) {
|
|
814
|
-
MIKOtaClientState* state = state_of(ctx);
|
|
815
|
-
CHECK_NOT_NULL(state);
|
|
816
|
-
|
|
817
|
-
JSValue obj = JS_NewObject(ctx);
|
|
818
|
-
MIKOtaConfigErrorReport report = {};
|
|
819
|
-
if (set_config_echo(ctx, obj, "rev", state->env, &report)) {
|
|
820
|
-
JSValue error = JS_NewObject(ctx);
|
|
821
|
-
JS_SetPropertyStr(ctx, error, "rev", JS_NewString(ctx, report.rev));
|
|
822
|
-
JS_SetPropertyStr(ctx, error, "message", JS_NewString(ctx, report.message));
|
|
823
|
-
JS_SetPropertyStr(ctx, obj, "error", error);
|
|
824
|
-
}
|
|
825
|
-
return obj;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
820
|
// ── the check-in exchange, for a client with its own transport ──────────────
|
|
829
821
|
//
|
|
830
822
|
// report() and settle() are the two halves the built-in client runs around its
|
|
@@ -833,6 +825,45 @@ JSValue ota_config_state(JSContext* ctx, JSValue, int, JSValue*) {
|
|
|
833
825
|
// touches is an existing policy call, so a client that goes through here and
|
|
834
826
|
// the built-in cannot disagree about what a round sends or settles.
|
|
835
827
|
|
|
828
|
+
/* decline(checksum, reason, detail?) — record why the last offered build was
|
|
829
|
+
* not taken, for the next report() to carry as lastDecline. The apply paths
|
|
830
|
+
* record their own outcomes; this is for a reason only the app can see, such
|
|
831
|
+
* as a download budget it keeps across wakes. The caps are the registry's: an
|
|
832
|
+
* oversized reason would have the whole check-in rejected, so it throws here,
|
|
833
|
+
* while detail is free text and is cut to fit instead. */
|
|
834
|
+
JSValue ota_decline(JSContext* ctx, JSValue, int argc, JSValue* argv) {
|
|
835
|
+
MIKOtaClientState* state = state_of(ctx);
|
|
836
|
+
CHECK_NOT_NULL(state);
|
|
837
|
+
|
|
838
|
+
const char* checksum =
|
|
839
|
+
argc > 0 && JS_IsString(argv[0]) ? JS_ToCString(ctx, argv[0]) : nullptr;
|
|
840
|
+
if (!checksum || !is_wire_checksum(checksum)) {
|
|
841
|
+
if (checksum) JS_FreeCString(ctx, checksum);
|
|
842
|
+
return JS_ThrowTypeError(ctx, "decline(): checksum must be 64 lowercase hex characters");
|
|
843
|
+
}
|
|
844
|
+
const char* reason = argc > 1 && JS_IsString(argv[1]) ? JS_ToCString(ctx, argv[1]) : nullptr;
|
|
845
|
+
if (!reason || reason[0] == '\0' || strlen(reason) > 64) {
|
|
846
|
+
JS_FreeCString(ctx, checksum);
|
|
847
|
+
if (reason) JS_FreeCString(ctx, reason);
|
|
848
|
+
return JS_ThrowTypeError(ctx, "decline(): reason must be 1 to 64 characters");
|
|
849
|
+
}
|
|
850
|
+
const char* detail = nullptr;
|
|
851
|
+
if (argc > 2 && !JS_IsUndefined(argv[2])) {
|
|
852
|
+
detail = JS_IsString(argv[2]) ? JS_ToCString(ctx, argv[2]) : nullptr;
|
|
853
|
+
if (!detail) {
|
|
854
|
+
JS_FreeCString(ctx, checksum);
|
|
855
|
+
JS_FreeCString(ctx, reason);
|
|
856
|
+
return JS_ThrowTypeError(ctx, "decline(): detail must be a string");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
note_decline(state->env, checksum, reason, detail ? detail : "");
|
|
861
|
+
JS_FreeCString(ctx, checksum);
|
|
862
|
+
JS_FreeCString(ctx, reason);
|
|
863
|
+
if (detail) JS_FreeCString(ctx, detail);
|
|
864
|
+
return JS_UNDEFINED;
|
|
865
|
+
}
|
|
866
|
+
|
|
836
867
|
/* report() -> the check-in body the device owes its registry, wire field
|
|
837
868
|
* shapes throughout so a proxy can forward fields verbatim. */
|
|
838
869
|
JSValue ota_report(JSContext* ctx, JSValue, int, JSValue*) {
|
|
@@ -876,6 +907,20 @@ JSValue ota_report(JSContext* ctx, JSValue, int, JSValue*) {
|
|
|
876
907
|
JS_SetPropertyStr(ctx, obj, "lastInstall", diag);
|
|
877
908
|
}
|
|
878
909
|
|
|
910
|
+
/* Why the last offered build was not taken, recorded by applyOffer or
|
|
911
|
+
* decline(). Without it a registry cannot tell a device still working
|
|
912
|
+
* through a download from one that has stopped. */
|
|
913
|
+
MIKOtaDeclineRecord declined;
|
|
914
|
+
if (mikrojs::MIKOtaStore(env).GetDecline(&declined)) {
|
|
915
|
+
JSValue last = JS_NewObject(ctx);
|
|
916
|
+
JS_SetPropertyStr(ctx, last, "checksum", JS_NewString(ctx, declined.checksum));
|
|
917
|
+
JS_SetPropertyStr(ctx, last, "reason", JS_NewString(ctx, declined.reason));
|
|
918
|
+
if (declined.detail[0]) {
|
|
919
|
+
JS_SetPropertyStr(ctx, last, "detail", JS_NewString(ctx, declined.detail));
|
|
920
|
+
}
|
|
921
|
+
JS_SetPropertyStr(ctx, obj, "lastDecline", last);
|
|
922
|
+
}
|
|
923
|
+
|
|
879
924
|
/* configRev / configError, through the same resolution as configState(). */
|
|
880
925
|
MIKOtaConfigErrorReport report = {};
|
|
881
926
|
if (set_config_echo(ctx, obj, "configRev", env, &report)) {
|
|
@@ -915,8 +960,10 @@ JSValue ota_settle(JSContext* ctx, JSValue, int argc, JSValue* argv) {
|
|
|
915
960
|
/* Confirm before the deliveries, as the built-in does: it must settle the
|
|
916
961
|
* document held BEFORE this round, never the one about to be armed. */
|
|
917
962
|
mikrojs::mik__ota_policy_confirm(state->env);
|
|
918
|
-
/* The round completed, so the cached install report
|
|
963
|
+
/* The round completed, so the cached install report and the stored decline
|
|
964
|
+
* record were delivered. */
|
|
919
965
|
state->has_last_install = false;
|
|
966
|
+
mikrojs::MIKOtaStore(state->env).ClearDecline();
|
|
920
967
|
|
|
921
968
|
bool renamed = false;
|
|
922
969
|
if (usable) {
|
|
@@ -957,7 +1004,7 @@ JSValue ota_settle(JSContext* ctx, JSValue, int argc, JSValue* argv) {
|
|
|
957
1004
|
JS_FreeValue(ctx, cfg_raw);
|
|
958
1005
|
|
|
959
1006
|
/* The offer fields are top-level in the response, so the whole body
|
|
960
|
-
* goes to the parser
|
|
1007
|
+
* goes to the offer parser. */
|
|
961
1008
|
bool allow_insecure = false;
|
|
962
1009
|
if (argc > 1 && JS_IsObject(argv[1])) {
|
|
963
1010
|
allow_insecure = opt_bool(ctx, argv[1], "allowInsecure", false);
|
|
@@ -976,21 +1023,13 @@ int mik__ota_client_module_init(JSContext* ctx, JSModuleDef* m) {
|
|
|
976
1023
|
JS_SetModuleExport(ctx, m, "watch", JS_NewCFunction(ctx, mik__ota_client_watch, "watch", 1));
|
|
977
1024
|
JS_SetModuleExport(ctx, m, "config", JS_NewCFunction(ctx, mik__ota_client_config, "config", 0));
|
|
978
1025
|
JS_SetModuleExport(ctx, m, "reconcile", JS_NewCFunction(ctx, ota_reconcile, "reconcile", 0));
|
|
979
|
-
JS_SetModuleExport(ctx, m, "running", JS_NewCFunction(ctx, ota_running, "running", 0));
|
|
980
1026
|
JS_SetModuleExport(ctx, m, "confirm", JS_NewCFunction(ctx, ota_confirm, "confirm", 0));
|
|
981
1027
|
JS_SetModuleExport(ctx, m, "revert", JS_NewCFunction(ctx, ota_revert, "revert", 0));
|
|
982
1028
|
JS_SetModuleExport(ctx, m, "bearer", JS_NewCFunction(ctx, ota_bearer, "bearer", 0));
|
|
983
1029
|
JS_SetModuleExport(ctx, m, "registry", JS_NewCFunction(ctx, ota_registry, "registry", 0));
|
|
984
|
-
JS_SetModuleExport(ctx, m, "parseOffer",
|
|
985
|
-
JS_NewCFunction(ctx, ota_parse_offer, "parseOffer", 2));
|
|
986
1030
|
JS_SetModuleExport(ctx, m, "applyOffer",
|
|
987
1031
|
JS_NewCFunction(ctx, ota_apply_offer, "applyOffer", 3));
|
|
988
|
-
JS_SetModuleExport(ctx, m, "
|
|
989
|
-
JS_NewCFunction(ctx, ota_parse_config, "parseConfig", 1));
|
|
990
|
-
JS_SetModuleExport(ctx, m, "applyConfig",
|
|
991
|
-
JS_NewCFunction(ctx, ota_apply_config, "applyConfig", 2));
|
|
992
|
-
JS_SetModuleExport(ctx, m, "configState",
|
|
993
|
-
JS_NewCFunction(ctx, ota_config_state, "configState", 0));
|
|
1032
|
+
JS_SetModuleExport(ctx, m, "decline", JS_NewCFunction(ctx, ota_decline, "decline", 3));
|
|
994
1033
|
JS_SetModuleExport(ctx, m, "report", JS_NewCFunction(ctx, ota_report, "report", 0));
|
|
995
1034
|
JS_SetModuleExport(ctx, m, "settle", JS_NewCFunction(ctx, ota_settle, "settle", 2));
|
|
996
1035
|
return 0;
|
|
@@ -1033,16 +1072,12 @@ JSModuleDef* mik__ota_client_init(JSContext* ctx) {
|
|
|
1033
1072
|
JS_AddModuleExport(ctx, m, "watch");
|
|
1034
1073
|
JS_AddModuleExport(ctx, m, "config");
|
|
1035
1074
|
JS_AddModuleExport(ctx, m, "reconcile");
|
|
1036
|
-
JS_AddModuleExport(ctx, m, "running");
|
|
1037
1075
|
JS_AddModuleExport(ctx, m, "confirm");
|
|
1038
1076
|
JS_AddModuleExport(ctx, m, "revert");
|
|
1039
1077
|
JS_AddModuleExport(ctx, m, "bearer");
|
|
1040
1078
|
JS_AddModuleExport(ctx, m, "registry");
|
|
1041
|
-
JS_AddModuleExport(ctx, m, "parseOffer");
|
|
1042
1079
|
JS_AddModuleExport(ctx, m, "applyOffer");
|
|
1043
|
-
JS_AddModuleExport(ctx, m, "
|
|
1044
|
-
JS_AddModuleExport(ctx, m, "applyConfig");
|
|
1045
|
-
JS_AddModuleExport(ctx, m, "configState");
|
|
1080
|
+
JS_AddModuleExport(ctx, m, "decline");
|
|
1046
1081
|
JS_AddModuleExport(ctx, m, "report");
|
|
1047
1082
|
JS_AddModuleExport(ctx, m, "settle");
|
|
1048
1083
|
return m;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
#include "esp_sleep.h"
|
|
4
4
|
#include "soc/soc_caps.h"
|
|
5
5
|
|
|
6
|
+
#include "mikrojs/platform.h"
|
|
6
7
|
#include "mikrojs/private.h"
|
|
7
8
|
#include "mikrojs/utils.h"
|
|
8
9
|
#include "mikrojs_esp32.h"
|
|
@@ -274,6 +275,12 @@ static JSValue mik__sleep_light(JSContext* ctx, JSValue this_val, int argc, JSVa
|
|
|
274
275
|
esp_err_t err = esp_light_sleep_start();
|
|
275
276
|
mik__serial_io_attach_usb();
|
|
276
277
|
|
|
278
|
+
/* esp_timer keeps counting through light sleep, so without this the
|
|
279
|
+
* sleep would count against the blocking and feed limits and the TWDT. */
|
|
280
|
+
const MIKPlatform* platform = MIK_GetPlatform();
|
|
281
|
+
if (platform->feed_watchdog) platform->feed_watchdog();
|
|
282
|
+
mik__watchdog_wake(MIK_GetRuntime(ctx));
|
|
283
|
+
|
|
277
284
|
if (err != ESP_OK)
|
|
278
285
|
return JS_ThrowInternalError(ctx, "light sleep failed: %s", esp_err_to_name(err));
|
|
279
286
|
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
#include <freertos/FreeRTOS.h>
|
|
15
15
|
#include <freertos/task.h>
|
|
16
16
|
#include <soc/soc_caps.h>
|
|
17
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
18
|
+
#include <esp_task_wdt.h>
|
|
19
|
+
#endif
|
|
17
20
|
|
|
18
21
|
#include <stdarg.h>
|
|
19
22
|
#include <stdio.h>
|
|
@@ -53,7 +56,20 @@ static void esp32_deep_sleep_us(uint64_t us) {
|
|
|
53
56
|
/* Never reached */
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
/* task_wdt.c is only compiled under CONFIG_ESP_TASK_WDT_EN (the header has no
|
|
60
|
+
* guard), so every TWDT call is wrapped; the test firmware turns it off. */
|
|
61
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
62
|
+
static void esp32_feed_watchdog(void) {
|
|
63
|
+
esp_task_wdt_reset();
|
|
64
|
+
}
|
|
65
|
+
#endif
|
|
66
|
+
|
|
56
67
|
static void esp32_yield(void) {
|
|
68
|
+
/* Every cooperative yield feeds the TWDT: between jobs, in the serve
|
|
69
|
+
* loop's wait, and while the app is paused. */
|
|
70
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
71
|
+
esp_task_wdt_reset();
|
|
72
|
+
#endif
|
|
57
73
|
vTaskDelay(1);
|
|
58
74
|
}
|
|
59
75
|
|
|
@@ -321,6 +337,11 @@ static const MIKPlatform esp32_platform = {
|
|
|
321
337
|
.restart = esp32_restart,
|
|
322
338
|
.deep_sleep_us = esp32_deep_sleep_us,
|
|
323
339
|
.yield = esp32_yield,
|
|
340
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
341
|
+
.feed_watchdog = esp32_feed_watchdog,
|
|
342
|
+
#else
|
|
343
|
+
.feed_watchdog = nullptr,
|
|
344
|
+
#endif
|
|
324
345
|
.get_free_system_mem = esp32_get_free_system_mem,
|
|
325
346
|
.get_min_free_system_mem = esp32_get_min_free_system_mem,
|
|
326
347
|
.get_total_system_mem = esp32_get_total_system_mem,
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#include <cerrno>
|
|
2
|
+
#include <cstring>
|
|
3
|
+
#include <string>
|
|
4
|
+
#include <vector>
|
|
5
|
+
|
|
6
|
+
#include "esp_timer.h"
|
|
7
|
+
#include "freertos/FreeRTOS.h"
|
|
8
|
+
#include "freertos/task.h"
|
|
9
|
+
#include "mikrojs.h"
|
|
10
|
+
#include "mikrojs/platform.h"
|
|
11
|
+
#include "private.h"
|
|
12
|
+
#include "quickjs.h"
|
|
13
|
+
#include "soc/soc_caps.h"
|
|
14
|
+
#include "unity.h"
|
|
15
|
+
|
|
16
|
+
/* The portable layers (blocking, feed, awake) are covered on the host. These
|
|
17
|
+
* check what only exists on silicon: the platform hook, lightSleep's budget
|
|
18
|
+
* re-stamp with esp_timer really counting through the sleep, the REPL
|
|
19
|
+
* surviving a timeout, and the limits MIK_SetConfig switches on.
|
|
20
|
+
* The test firmware builds with CONFIG_ESP_TASK_WDT_EN=n, so nothing here
|
|
21
|
+
* can reset the chip. */
|
|
22
|
+
|
|
23
|
+
static MIKRuntime* rt;
|
|
24
|
+
static JSContext* ctx;
|
|
25
|
+
|
|
26
|
+
static void setup(int blocking_ms, int feed_ms, int awake_ms) {
|
|
27
|
+
rt = MIK_NewRuntime();
|
|
28
|
+
ctx = MIK_GetJSContext(rt);
|
|
29
|
+
MIKConfig cfg;
|
|
30
|
+
MIK_DefaultConfig(&cfg);
|
|
31
|
+
cfg.blocking_timeout_ms = blocking_ms;
|
|
32
|
+
cfg.feed_timeout_ms = feed_ms;
|
|
33
|
+
cfg.awake_timeout_ms = awake_ms;
|
|
34
|
+
MIK_SetConfig(rt, &cfg);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static void teardown() { MIK_FreeRuntime(rt); }
|
|
38
|
+
|
|
39
|
+
static JSValue eval_script(const char* code) {
|
|
40
|
+
return JS_Eval(ctx, code, strlen(code), "test.js", JS_EVAL_TYPE_GLOBAL);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static JSValue eval_module(const char* code) {
|
|
44
|
+
JSValue ret = MIK_EvalModuleContent(ctx, "mikro/test", code, strlen(code));
|
|
45
|
+
if (!JS_IsException(ret)) {
|
|
46
|
+
JS_FreeValue(ctx, ret);
|
|
47
|
+
mik__execute_jobs(ctx);
|
|
48
|
+
}
|
|
49
|
+
return ret;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static bool global_bool(const char* name) {
|
|
53
|
+
JSValue global = JS_GetGlobalObject(ctx);
|
|
54
|
+
JSValue v = JS_GetPropertyStr(ctx, global, name);
|
|
55
|
+
bool b = JS_ToBool(ctx, v) == 1;
|
|
56
|
+
JS_FreeValue(ctx, v);
|
|
57
|
+
JS_FreeValue(ctx, global);
|
|
58
|
+
return b;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Pump MIK_Loop until it reports a stop or `ms` elapse. True when stopped. */
|
|
62
|
+
static bool pump_until_stop(int ms) {
|
|
63
|
+
int64_t deadline = esp_timer_get_time() + (int64_t)ms * 1000;
|
|
64
|
+
while (esp_timer_get_time() < deadline) {
|
|
65
|
+
if (MIK_Loop(rt) != 0) return true;
|
|
66
|
+
vTaskDelay(pdMS_TO_TICKS(10));
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── Platform hook ───────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
TEST_CASE("feed_watchdog follows CONFIG_ESP_TASK_WDT_EN", "[watchdog]") {
|
|
74
|
+
const MIKPlatform* platform = MIK_GetPlatform();
|
|
75
|
+
#if CONFIG_ESP_TASK_WDT_EN
|
|
76
|
+
TEST_ASSERT_NOT_NULL(platform->feed_watchdog);
|
|
77
|
+
platform->feed_watchdog(); /* unsubscribed task: must be harmless */
|
|
78
|
+
#else
|
|
79
|
+
TEST_ASSERT_NULL(platform->feed_watchdog);
|
|
80
|
+
#endif
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ── Blocking deadline ───────────────────────────────────────────── */
|
|
84
|
+
|
|
85
|
+
TEST_CASE("blocking watchdog interrupts a runaway loop", "[watchdog]") {
|
|
86
|
+
setup(1000, 0, 0);
|
|
87
|
+
int64_t t0 = esp_timer_get_time();
|
|
88
|
+
JSValue ret = eval_script("while (true) {}");
|
|
89
|
+
int64_t elapsed_ms = (esp_timer_get_time() - t0) / 1000;
|
|
90
|
+
TEST_ASSERT_TRUE_MESSAGE(JS_IsException(ret), "runaway loop should be interrupted");
|
|
91
|
+
JSValue exc = JS_GetException(ctx);
|
|
92
|
+
TEST_ASSERT_TRUE_MESSAGE(JS_IsUncatchableError(exc), "the interrupt must be uncatchable");
|
|
93
|
+
JS_FreeValue(ctx, exc);
|
|
94
|
+
TEST_ASSERT_TRUE_MESSAGE(elapsed_ms >= 900 && elapsed_ms < 5000,
|
|
95
|
+
"should fire near the 1 s budget");
|
|
96
|
+
teardown();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
TEST_CASE("blocking watchdog error cannot be caught", "[watchdog]") {
|
|
100
|
+
setup(1000, 0, 0);
|
|
101
|
+
JSValue ret = eval_script(R"(
|
|
102
|
+
globalThis.__caught = false;
|
|
103
|
+
try { while (true) {} } catch (e) { globalThis.__caught = true; }
|
|
104
|
+
)");
|
|
105
|
+
TEST_ASSERT_TRUE_MESSAGE(JS_IsException(ret), "runaway loop should be interrupted");
|
|
106
|
+
JSValue exc = JS_GetException(ctx);
|
|
107
|
+
JS_FreeValue(ctx, exc);
|
|
108
|
+
TEST_ASSERT_FALSE_MESSAGE(global_bool("__caught"), "try/catch must not swallow the interrupt");
|
|
109
|
+
teardown();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
TEST_CASE("blocking watchdog leaves a loop under budget alone", "[watchdog]") {
|
|
113
|
+
setup(2000, 0, 0);
|
|
114
|
+
JSValue ret = eval_script(R"(
|
|
115
|
+
const end = Date.now() + 500;
|
|
116
|
+
while (Date.now() < end) {}
|
|
117
|
+
globalThis.__done = true;
|
|
118
|
+
)");
|
|
119
|
+
TEST_ASSERT_FALSE_MESSAGE(JS_IsException(ret), "loop under budget must not be interrupted");
|
|
120
|
+
JS_FreeValue(ctx, ret);
|
|
121
|
+
TEST_ASSERT_TRUE(global_bool("__done"));
|
|
122
|
+
teardown();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Light sleep disconnects the UART console on targets without USB
|
|
126
|
+
Serial/JTAG, which stalls the test monitor. Same skip as sleep_test.cpp. */
|
|
127
|
+
TEST_CASE("lightSleep past the blocking budget does not fire", "[watchdog]") {
|
|
128
|
+
#if !SOC_USB_SERIAL_JTAG_SUPPORTED
|
|
129
|
+
TEST_IGNORE_MESSAGE("light sleep disconnects UART console, skipped on non-USB targets");
|
|
130
|
+
#endif
|
|
131
|
+
setup(1000, 0, 0);
|
|
132
|
+
JSValue ret = eval_module(R"(
|
|
133
|
+
import { lightSleep } from "native:mikro/sleep";
|
|
134
|
+
const spin = (ms) => { const end = Date.now() + ms; while (Date.now() < end) {} };
|
|
135
|
+
spin(300); /* lands a budget stamp well before the sleep */
|
|
136
|
+
try {
|
|
137
|
+
lightSleep({timer: 1500000});
|
|
138
|
+
} catch (e) {
|
|
139
|
+
globalThis.__skipped = true; /* board cannot light-sleep; not a watchdog failure */
|
|
140
|
+
}
|
|
141
|
+
spin(300); /* over budget only if the sleep was counted */
|
|
142
|
+
globalThis.__done = true;
|
|
143
|
+
)");
|
|
144
|
+
TEST_ASSERT_FALSE_MESSAGE(JS_IsException(ret), "Module eval should not throw");
|
|
145
|
+
if (global_bool("__skipped")) {
|
|
146
|
+
teardown();
|
|
147
|
+
TEST_IGNORE_MESSAGE("lightSleep unavailable on this board");
|
|
148
|
+
}
|
|
149
|
+
TEST_ASSERT_TRUE_MESSAGE(global_bool("__done"),
|
|
150
|
+
"lightSleep must not count against the blocking budget");
|
|
151
|
+
teardown();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ── Feed deadline ───────────────────────────────────────────────── */
|
|
155
|
+
|
|
156
|
+
TEST_CASE("feed deadline stops a runtime that never feeds", "[watchdog]") {
|
|
157
|
+
setup(0, 1000, 0);
|
|
158
|
+
TEST_ASSERT_TRUE_MESSAGE(pump_until_stop(5000),
|
|
159
|
+
"loop should stop once the feed deadline passes");
|
|
160
|
+
TEST_ASSERT_TRUE(MIK_IsStopRequested(rt));
|
|
161
|
+
teardown();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
TEST_CASE("watchdog.feed() keeps the feed deadline from firing", "[watchdog]") {
|
|
165
|
+
setup(0, 1000, 0);
|
|
166
|
+
JSValue ret = eval_module(R"(
|
|
167
|
+
import { watchdog } from "mikro/watchdog";
|
|
168
|
+
setInterval(() => watchdog.feed(), 100);
|
|
169
|
+
)");
|
|
170
|
+
TEST_ASSERT_FALSE_MESSAGE(JS_IsException(ret), "Module eval should not throw");
|
|
171
|
+
TEST_ASSERT_FALSE_MESSAGE(pump_until_stop(2500), "a fed runtime must not stop");
|
|
172
|
+
teardown();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
TEST_CASE("watchdog.feed() without a feed budget is a no-op", "[watchdog]") {
|
|
176
|
+
setup(0, 0, 0);
|
|
177
|
+
JSValue ret = eval_module(R"(
|
|
178
|
+
import { watchdog } from "mikro/watchdog";
|
|
179
|
+
watchdog.feed();
|
|
180
|
+
globalThis.__ok = true;
|
|
181
|
+
)");
|
|
182
|
+
TEST_ASSERT_FALSE_MESSAGE(JS_IsException(ret), "Module eval should not throw");
|
|
183
|
+
TEST_ASSERT_TRUE(global_bool("__ok"));
|
|
184
|
+
teardown();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ── Awake budget ────────────────────────────────────────────────── */
|
|
188
|
+
|
|
189
|
+
static int s_error_calls;
|
|
190
|
+
static void count_errors(JSContext*, JSValue, void*) { s_error_calls++; }
|
|
191
|
+
|
|
192
|
+
TEST_CASE("awake budget stops the runtime without a JS error", "[watchdog]") {
|
|
193
|
+
/* The budget counts from boot, so on a device that has been running the
|
|
194
|
+
* suite it is already spent when the runtime starts counting. */
|
|
195
|
+
setup(0, 0, 1000);
|
|
196
|
+
s_error_calls = 0;
|
|
197
|
+
MIK_SetErrorHandler(rt, count_errors, nullptr);
|
|
198
|
+
TEST_ASSERT_TRUE_MESSAGE(pump_until_stop(3000), "loop should stop once the budget elapses");
|
|
199
|
+
TEST_ASSERT_TRUE(MIK_IsStopRequested(rt));
|
|
200
|
+
TEST_ASSERT_EQUAL_MESSAGE(0, s_error_calls,
|
|
201
|
+
"awake must not reach the error handler (OTA trial rollback)");
|
|
202
|
+
teardown();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ── REPL survives a timeout ────────────────────────────────────────── */
|
|
206
|
+
|
|
207
|
+
struct MockTransport {
|
|
208
|
+
std::vector<uint8_t> input;
|
|
209
|
+
size_t pos = 0;
|
|
210
|
+
std::vector<uint8_t> output;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
static int mock_read(uint8_t* buf, size_t size, void* opaque) {
|
|
214
|
+
auto* m = static_cast<MockTransport*>(opaque);
|
|
215
|
+
if (m->pos >= m->input.size()) {
|
|
216
|
+
errno = 0; /* not EAGAIN: true EOF */
|
|
217
|
+
return -1;
|
|
218
|
+
}
|
|
219
|
+
size_t n = m->input.size() - m->pos;
|
|
220
|
+
if (n > size) n = size;
|
|
221
|
+
memcpy(buf, m->input.data() + m->pos, n);
|
|
222
|
+
m->pos += n;
|
|
223
|
+
return (int)n;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
static void mock_write(const void* buf, size_t len, void* opaque) {
|
|
227
|
+
auto* m = static_cast<MockTransport*>(opaque);
|
|
228
|
+
auto* bytes = static_cast<const uint8_t*>(buf);
|
|
229
|
+
m->output.insert(m->output.end(), bytes, bytes + len);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
static void append_frame(std::vector<uint8_t>& buf, uint8_t type, const char* payload) {
|
|
233
|
+
uint32_t len = payload ? (uint32_t)strlen(payload) : 0;
|
|
234
|
+
buf.push_back(type);
|
|
235
|
+
for (int i = 0; i < 4; i++) buf.push_back((uint8_t)(len >> (8 * i)));
|
|
236
|
+
if (len > 0) buf.insert(buf.end(), payload, payload + len);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* Payload of the first frame of `type`, or empty when none was sent. */
|
|
240
|
+
static std::string find_payload(const std::vector<uint8_t>& data, uint8_t type) {
|
|
241
|
+
size_t pos = 0;
|
|
242
|
+
while (pos + MIK_PROTO_HEADER_SIZE <= data.size()) {
|
|
243
|
+
uint32_t len = (uint32_t)data[pos + 1] | ((uint32_t)data[pos + 2] << 8) |
|
|
244
|
+
((uint32_t)data[pos + 3] << 16) | ((uint32_t)data[pos + 4] << 24);
|
|
245
|
+
if (pos + MIK_PROTO_HEADER_SIZE + len > data.size()) break;
|
|
246
|
+
if (data[pos] == type) {
|
|
247
|
+
return std::string((const char*)data.data() + pos + MIK_PROTO_HEADER_SIZE, len);
|
|
248
|
+
}
|
|
249
|
+
pos += MIK_PROTO_HEADER_SIZE + len;
|
|
250
|
+
}
|
|
251
|
+
return "";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
TEST_CASE("REPL eval of a runaway loop reports an error and the session survives",
|
|
255
|
+
"[watchdog]") {
|
|
256
|
+
setup(1000, 0, 0);
|
|
257
|
+
MockTransport mock;
|
|
258
|
+
append_frame(mock.input, MIK_CMD_EVAL, "while (true) {}");
|
|
259
|
+
append_frame(mock.input, MIK_CMD_EVAL, "1 + 2");
|
|
260
|
+
append_frame(mock.input, MIK_CMD_EXIT, nullptr);
|
|
261
|
+
|
|
262
|
+
MIKReplTransport transport = {};
|
|
263
|
+
transport.read = mock_read;
|
|
264
|
+
transport.write = mock_write;
|
|
265
|
+
transport.ctx = &mock;
|
|
266
|
+
MIK_ProtocolOpen(&transport);
|
|
267
|
+
MIK_ProtocolAttach(rt);
|
|
268
|
+
MIK_ProtocolServeLoop();
|
|
269
|
+
MIK_ProtocolDetach();
|
|
270
|
+
MIK_ProtocolClose();
|
|
271
|
+
|
|
272
|
+
std::string err = find_payload(mock.output, MIK_MSG_EVAL_ERROR);
|
|
273
|
+
TEST_ASSERT_TRUE_MESSAGE(err.find("InternalError") != std::string::npos,
|
|
274
|
+
"runaway eval should report the interrupt as an eval error");
|
|
275
|
+
std::string result = find_payload(mock.output, MIK_MSG_RESULT);
|
|
276
|
+
TEST_ASSERT_EQUAL_STRING_MESSAGE("3", result.c_str(),
|
|
277
|
+
"the next eval should still answer");
|
|
278
|
+
TEST_ASSERT_FALSE_MESSAGE(MIK_IsStopRequested(rt), "a REPL timeout must not stop the runtime");
|
|
279
|
+
teardown();
|
|
280
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikrojs/firmware",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0-next.20260904225731",
|
|
4
4
|
"description": "Mikro.js ESP32 firmware: ESP-IDF component, build tools, and project template",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"esp-idf",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"esbuild": "^0.28.0",
|
|
50
|
-
"@mikrojs/
|
|
51
|
-
"@mikrojs/
|
|
50
|
+
"@mikrojs/native": "0.20.0-next.20260904225731+071a2a9",
|
|
51
|
+
"@mikrojs/quickjs": "0.20.0-next.20260904225731+071a2a9"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.0.0"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/sdkconfig.defaults
CHANGED
|
@@ -68,6 +68,16 @@ CONFIG_ESP_EVENT_POST_FROM_IRAM_ISR=n
|
|
|
68
68
|
# Without this, the 128-byte RX FIFO overflows during deploy.
|
|
69
69
|
CONFIG_UART_ISR_IN_IRAM=y
|
|
70
70
|
|
|
71
|
+
# --- Task watchdog ---
|
|
72
|
+
# The main task (which runs the JS runtime) subscribes in MIK_Main and is fed
|
|
73
|
+
# from MIK_Loop and platform->yield. PANIC makes a timeout reset the chip
|
|
74
|
+
# instead of only logging; it also applies to the idle-task checks, so any
|
|
75
|
+
# task that starves idle for the timeout reboots the device. The JS blocking
|
|
76
|
+
# watchdog (30 s default) must fire first; mik_main.cpp asserts the order.
|
|
77
|
+
# 60 is the Kconfig ceiling.
|
|
78
|
+
CONFIG_ESP_TASK_WDT_PANIC=y
|
|
79
|
+
CONFIG_ESP_TASK_WDT_TIMEOUT_S=60
|
|
80
|
+
|
|
71
81
|
# --- LittleFS ---
|
|
72
82
|
# Track modification time so fs.stat() / fs.readDir() surface mtime
|
|
73
83
|
# (exposed as ms-since-epoch on the StatResult object).
|