@mikrojs/firmware 0.19.0 → 0.20.0

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.
@@ -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
 
@@ -551,6 +606,14 @@ void MIK_Main(void) {
551
606
  transport.session_end = platform_session_end;
552
607
  transport.session_end_ctx = nullptr;
553
608
 
609
+ /* Record the memory the app starts from, taken here rather than at the
610
+ * first runtime attach: in test mode that attach comes after the test path
611
+ * list, the supervisor's scratch buffer, the test helper globals and a
612
+ * runtime free/create cycle, so the reading would describe the harness
613
+ * instead of the app. Nothing below this line has run yet in either mode,
614
+ * so both report the same figure. */
615
+ MIK_CaptureBootMemory(mik_rt);
616
+
554
617
  /* Test-harness mode: if package.json carries a non-empty "tests" array,
555
618
  * run each listed file in its own fresh runtime through one transport
556
619
  * session. The test runtime calls __testFileDone after emitting its
@@ -710,6 +773,9 @@ void MIK_Main(void) {
710
773
  MIK_ProtocolClose();
711
774
  MIK_FreeTests(test_paths, test_count);
712
775
 
713
- /* If we get here, the transport died. Restart. */
776
+ /* If we get here, the transport died. Restart, and say so: this reset
777
+ * reads as "software" on the next boot, same as a host restart. */
778
+ printf("[mikrojs] console transport closed, restarting\n");
779
+ fflush(stdout);
714
780
  esp_restart();
715
781
  }
@@ -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) sha256_update(&c, buf, n);
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() -> { checksum?, version?, trial } */
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
- /* parseConfig(raw) -> StoredConfig | undefined */
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 was delivered. */
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, exactly as ota.parseOffer(body) would. */
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, "parseConfig",
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, "parseConfig");
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,
@@ -24,6 +24,7 @@ set(_MIK_TEST_SRCS
24
24
  timers_js_test.cpp
25
25
  timers_test.cpp
26
26
  udp_test.cpp
27
+ watchdog_test.cpp
27
28
  wifi_test.cpp
28
29
  )
29
30
  if(CONFIG_BT_ENABLED)
@@ -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.19.0",
3
+ "version": "0.20.0",
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/quickjs": "0.19.0",
51
- "@mikrojs/native": "0.19.0"
50
+ "@mikrojs/native": "0.20.0",
51
+ "@mikrojs/quickjs": "0.20.0"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.0.0"
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -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).