@mikrojs/firmware 0.18.1 → 0.18.2

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/README.md CHANGED
@@ -26,6 +26,6 @@ See the [Custom Firmware](https://mikrojs.dev/develop/custom-firmware) docs for
26
26
  ## Requirements
27
27
 
28
28
  - Node.js >= 24
29
- - ESP-IDF >= 6.0.1 (installed via [EIM](https://docs.espressif.com/projects/idf-im-ui/en/latest/))
29
+ - ESP-IDF >= 6.1 (installed via [EIM](https://docs.espressif.com/projects/idf-im-ui/en/latest/))
30
30
 
31
31
  To build, activate ESP-IDF in your shell first: run `eim select` and source the activation script it prints, then use `idf.py` as usual.
@@ -1480,7 +1480,7 @@ static int mik__ble_module_init(JSContext* ctx, JSModuleDef* m) {
1480
1480
  static JSModuleDef* mik__ble_init(JSContext* ctx) {
1481
1481
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
1482
1482
  CHECK_NOT_NULL(mik_rt);
1483
- mik__ble_slot = MIK_AllocModuleSlot(mik_rt);
1483
+ if (mik__ble_slot < 0) mik__ble_slot = MIK_ReserveModuleSlot();
1484
1484
 
1485
1485
  auto* state = new MIKBleState();
1486
1486
  mik__ble_st(mik_rt) = state;
@@ -923,7 +923,7 @@ static void mik__http_ensure_initialized(JSContext* ctx) {
923
923
  void mik__http_ensure_native(JSContext* ctx) {
924
924
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
925
925
  if (!mik_rt) return;
926
- if (mik__http_slot < 0) mik__http_slot = MIK_AllocModuleSlot(mik_rt);
926
+ if (mik__http_slot < 0) mik__http_slot = MIK_ReserveModuleSlot();
927
927
  mik__http_ensure_initialized(ctx);
928
928
  MIK_RegisterLoopConsumer(mik_rt, mik__http_consume, mik__http_destroy);
929
929
  }
@@ -941,10 +941,9 @@ static int mik__http_module_init(JSContext* ctx, JSModuleDef* m) {
941
941
  }
942
942
 
943
943
  static JSModuleDef* mik__http_init(JSContext* ctx) {
944
- MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
945
- /* The slot may already exist: a native consumer can bring the transport up
946
- * before anything imports the JS module. */
947
- if (mik__http_slot < 0) mik__http_slot = MIK_AllocModuleSlot(mik_rt);
944
+ /* The slot may already be reserved: a native consumer can bring the
945
+ * transport up before anything imports the JS module. */
946
+ if (mik__http_slot < 0) mik__http_slot = MIK_ReserveModuleSlot();
948
947
 
949
948
  JSModuleDef* m = JS_NewCModule(ctx, "native:mikro/http", mik__http_module_init);
950
949
  if (!m) return nullptr;
@@ -973,10 +972,12 @@ void mik__http_consume(JSContext* ctx) {
973
972
  if (!p->sink.done || p->deadline_us == 0 || now_us < p->deadline_us) continue;
974
973
  MIKHttpNativeSink sink = p->sink;
975
974
  /* Stop delivering and let the task's terminal message drop the entry;
976
- * the sink hears about it once, here. */
975
+ * the sink hears about it once, here. `done` must stay set: it is what
976
+ * marks the entry native, and js_cancelled is what suppresses the
977
+ * second call. Clearing it would route the terminal message into the
978
+ * JS branch, onto promises a native entry never created. */
977
979
  p->sink.headers = nullptr;
978
980
  p->sink.data = nullptr;
979
- p->sink.done = nullptr;
980
981
  p->js_cancelled = true;
981
982
  p->deadline_us = 0;
982
983
  if (p->cancelled) p->cancelled->store(true, std::memory_order_relaxed);
@@ -671,8 +671,7 @@ static int mik__hs_module_init(JSContext* ctx, JSModuleDef* m) {
671
671
  }
672
672
 
673
673
  static JSModuleDef* mik__http_server_init(JSContext* ctx) {
674
- MIKRuntime* rt = MIK_GetRuntime(ctx);
675
- mik__http_server_slot = MIK_AllocModuleSlot(rt);
674
+ if (mik__http_server_slot < 0) mik__http_server_slot = MIK_ReserveModuleSlot();
676
675
 
677
676
  JSModuleDef* m = JS_NewCModule(ctx, "native:mikro/http_server", mik__hs_module_init);
678
677
  if (!m) return nullptr;
@@ -663,7 +663,7 @@ static int mik__i2s_module_init(JSContext* ctx, JSModuleDef* m) {
663
663
 
664
664
  static JSModuleDef* mik__i2s_init(JSContext* ctx) {
665
665
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
666
- mik__i2s_slot = MIK_AllocModuleSlot(mik_rt);
666
+ if (mik__i2s_slot < 0) mik__i2s_slot = MIK_ReserveModuleSlot();
667
667
 
668
668
  auto* slot_data = static_cast<MIKI2sSlot*>(calloc(1, sizeof(MIKI2sSlot)));
669
669
  if (!slot_data) return nullptr;
@@ -14,10 +14,12 @@
14
14
  #include "esp_log.h"
15
15
  #include "mik_http_internal.h"
16
16
  #include "mik_ota_native.h"
17
+ #include "mikrojs/cbor_helpers.h"
17
18
  #include "mikrojs/ota_client.h"
18
19
  #include "mikrojs/ota_config.h"
19
20
  #include "mikrojs/ota_js_hooks.h"
20
21
  #include "mikrojs/ota_policy.h"
22
+ #include "mikrojs/ota_slots.h"
21
23
  #include "mikrojs/private.h"
22
24
  #include "mikrojs/utils.h"
23
25
 
@@ -26,6 +28,7 @@ using mikrojs::MIKOtaCheckResult;
26
28
  using mikrojs::MIKOtaCheckStatus;
27
29
  using mikrojs::MIKOtaClient;
28
30
  using mikrojs::MIKOtaConfigReader;
31
+ using mikrojs::MIKOtaConfigWrite;
29
32
  using mikrojs::MIKOtaJsHooks;
30
33
  using mikrojs::MIKOtaApplyOutcome;
31
34
  using mikrojs::MIKOtaApplySession;
@@ -628,6 +631,174 @@ JSValue ota_apply_offer(JSContext* ctx, JSValue, int argc, JSValue* argv) {
628
631
  return promise;
629
632
  }
630
633
 
634
+ // ── config delivery ─────────────────────────────────────────────────────────
635
+ //
636
+ // The write side of config sync, for a client that received a document over its
637
+ // own transport. Where the slot policy lives is src/mik_ota_config.cpp, shared
638
+ // with the built-in client, so the two cannot disagree about the trial or the
639
+ // rollback baseline. What is here is the marshalling.
640
+
641
+ /* Copy a string field into a fixed buffer. False when it is the wrong type or
642
+ * too long to hold: a truncated rev never matches the one the registry issued,
643
+ * so the device would be served the same document forever. */
644
+ bool take_config_field(JSContext* ctx, JSValue obj, const char* key, char* out, size_t out_len,
645
+ bool* out_present) {
646
+ *out_present = false;
647
+ JSValue value = JS_GetPropertyStr(ctx, obj, key);
648
+ if (JS_IsUndefined(value) || JS_IsNull(value)) {
649
+ JS_FreeValue(ctx, value);
650
+ return true;
651
+ }
652
+ if (!JS_IsString(value)) {
653
+ JS_FreeValue(ctx, value);
654
+ return false;
655
+ }
656
+ const char* text = JS_ToCString(ctx, value);
657
+ JS_FreeValue(ctx, value);
658
+ if (!text) return false;
659
+ bool fits = strlen(text) < out_len;
660
+ if (fits) {
661
+ snprintf(out, out_len, "%s", text);
662
+ *out_present = text[0] != '\0';
663
+ }
664
+ JS_FreeCString(ctx, text);
665
+ return fits;
666
+ }
667
+
668
+ /* Validate a raw value into the fields a stored config carries. `*out_doc` is
669
+ * the document to store, or JS_UNDEFINED for the clear; the caller owns it. */
670
+ bool config_from_js(JSContext* ctx, JSValue raw, MIKOtaStoredConfig* out, JSValue* out_doc) {
671
+ *out_doc = JS_UNDEFINED;
672
+ if (!JS_IsObject(raw) || JS_IsArray(raw) || JS_IsFunction(ctx, raw)) return false;
673
+
674
+ /* The stamp is what says which release the document was computed for, and
675
+ * every read is decided by it. A document without one cannot be placed. */
676
+ bool has_version = false;
677
+ if (!take_config_field(ctx, raw, "version", out->version, sizeof(out->version),
678
+ &has_version)) {
679
+ return false;
680
+ }
681
+ if (!has_version) return false;
682
+
683
+ bool has_rev = false;
684
+ if (!take_config_field(ctx, raw, "rev", out->rev, sizeof(out->rev), &has_rev)) return false;
685
+
686
+ JSValue doc = JS_GetPropertyStr(ctx, raw, "doc");
687
+ if (JS_IsUndefined(doc) || JS_IsNull(doc)) {
688
+ /* An absent document is the clear, not a malformed config. */
689
+ JS_FreeValue(ctx, doc);
690
+ return true;
691
+ }
692
+ /* The document is spread over the manifest defaults, top level only.
693
+ * Anything that is not a plain object has no keys to spread, so storing one
694
+ * would read back as an empty overlay rather than as the error it is. */
695
+ if (!JS_IsObject(doc) || JS_IsArray(doc) || JS_IsFunction(ctx, doc)) {
696
+ JS_FreeValue(ctx, doc);
697
+ return false;
698
+ }
699
+ *out_doc = doc;
700
+ return true;
701
+ }
702
+
703
+ /* Encode a document to the CBOR the slots store. False for a value CBOR cannot
704
+ * carry (a function, a cycle, past the depth limit). */
705
+ bool encode_config_doc(JSContext* ctx, JSValue doc, std::vector<uint8_t>* out) {
706
+ /* Pass 1 sizes it against a zero-capacity buffer; the base pointer must be
707
+ * non-null because a zero-length append still reaches memcpy. */
708
+ static uint8_t measure_base;
709
+ nanocbor_encoder_t enc;
710
+ nanocbor_encoder_init(&enc, &measure_base, 0);
711
+ bool ok = mik__cbor_encode_value(ctx, &enc, doc, 0) >= 0;
712
+ size_t needed = nanocbor_encoded_len(&enc);
713
+ if (ok && needed > 0) {
714
+ out->resize(needed);
715
+ nanocbor_encoder_init(&enc, out->data(), needed);
716
+ ok = mik__cbor_encode_value(ctx, &enc, doc, 0) >= 0;
717
+ } else {
718
+ ok = false;
719
+ }
720
+ /* Property enumeration can throw on the way out, and an exception left on
721
+ * the context would surface at whatever ran next. */
722
+ if (JS_HasException(ctx)) JS_FreeValue(ctx, JS_GetException(ctx));
723
+ return ok;
724
+ }
725
+
726
+ /* parseConfig(raw) -> StoredConfig | undefined */
727
+ JSValue ota_parse_config(JSContext* ctx, JSValue, int argc, JSValue* argv) {
728
+ MIKOtaStoredConfig cfg = {};
729
+ JSValue doc = JS_UNDEFINED;
730
+ if (argc < 1 || !config_from_js(ctx, argv[0], &cfg, &doc)) {
731
+ JS_FreeValue(ctx, doc);
732
+ return JS_UNDEFINED;
733
+ }
734
+ JSValue obj = JS_NewObject(ctx);
735
+ if (cfg.rev[0]) JS_SetPropertyStr(ctx, obj, "rev", JS_NewString(ctx, cfg.rev));
736
+ JS_SetPropertyStr(ctx, obj, "version", JS_NewString(ctx, cfg.version));
737
+ if (JS_IsUndefined(doc)) return obj;
738
+ /* The document travels on as it arrived: whether it survives CBOR is
739
+ * settled by applyConfig, which is where the bytes are actually made. */
740
+ JS_SetPropertyStr(ctx, obj, "doc", doc);
741
+ return obj;
742
+ }
743
+
744
+ /* applyConfig(cfg, {trialBoots}) -> ConfigWrite */
745
+ JSValue ota_apply_config(JSContext* ctx, JSValue, int argc, JSValue* argv) {
746
+ MIKOtaClientState* state = state_of(ctx);
747
+ CHECK_NOT_NULL(state);
748
+
749
+ MIKOtaStoredConfig cfg = {};
750
+ JSValue doc = JS_UNDEFINED;
751
+ if (argc < 1 || !config_from_js(ctx, argv[0], &cfg, &doc)) {
752
+ JS_FreeValue(ctx, doc);
753
+ return JS_NewString(ctx, "invalid");
754
+ }
755
+
756
+ /* Held until the write is done: the stored config's span points into it. */
757
+ std::vector<uint8_t> bytes;
758
+ if (!JS_IsUndefined(doc)) {
759
+ bool encoded = encode_config_doc(ctx, doc, &bytes);
760
+ JS_FreeValue(ctx, doc);
761
+ if (!encoded) return JS_NewString(ctx, "invalid");
762
+ cfg.doc_cbor = bytes.data();
763
+ cfg.doc_cbor_len = bytes.size();
764
+ }
765
+
766
+ MIKOtaCheckOptions defaults;
767
+ int trial_boots = defaults.trial_boots;
768
+ if (argc > 1 && JS_IsObject(argv[1])) {
769
+ trial_boots = (int)opt_u32(ctx, argv[1], "trialBoots", (uint32_t)trial_boots);
770
+ }
771
+ /* A budget of zero would roll the document back on the first read that
772
+ * serves it, which is never what an app meant by delivering one. */
773
+ if (trial_boots < 1) trial_boots = 1;
774
+
775
+ MIKOtaConfigWrite write = mikrojs::mik__ota_deliver_config(state->env, &cfg, trial_boots);
776
+ return JS_NewString(ctx, mikrojs::mik__ota_config_write_to_str(write));
777
+ }
778
+
779
+ /* configState() -> {rev?, error?} */
780
+ JSValue ota_config_state(JSContext* ctx, JSValue, int, JSValue*) {
781
+ MIKOtaClientState* state = state_of(ctx);
782
+ CHECK_NOT_NULL(state);
783
+
784
+ JSValue obj = JS_NewObject(ctx);
785
+ MIKOtaConfigErrorReport report = {};
786
+ bool has_error = mikrojs::mik__ota_load_config_error(state->env, &report);
787
+ if (has_error) {
788
+ JSValue error = JS_NewObject(ctx);
789
+ JS_SetPropertyStr(ctx, error, "rev", JS_NewString(ctx, report.rev));
790
+ JS_SetPropertyStr(ctx, error, "message", JS_NewString(ctx, report.message));
791
+ JS_SetPropertyStr(ctx, obj, "error", error);
792
+ }
793
+ /* After a rollback the FAILED document's rev is the one to echo, not the
794
+ * restored one's: echoed-equals-held is what stops the registry serving the
795
+ * document that just failed, until an operator changes it. */
796
+ mikrojs::MIKOtaLoadedConfig held = mikrojs::mik__ota_load_slot(state->env, MIK_OTA_CFG_CURRENT);
797
+ const char* rev = has_error ? report.rev : (held.present ? held.cfg.rev : "");
798
+ if (rev[0]) JS_SetPropertyStr(ctx, obj, "rev", JS_NewString(ctx, rev));
799
+ return obj;
800
+ }
801
+
631
802
  int mik__ota_client_module_init(JSContext* ctx, JSModuleDef* m) {
632
803
  JS_SetModuleExport(ctx, m, "check", JS_NewCFunction(ctx, mik__ota_client_check, "check", 1));
633
804
  JS_SetModuleExport(ctx, m, "watch", JS_NewCFunction(ctx, mik__ota_client_watch, "watch", 1));
@@ -642,6 +813,12 @@ int mik__ota_client_module_init(JSContext* ctx, JSModuleDef* m) {
642
813
  JS_NewCFunction(ctx, ota_parse_offer, "parseOffer", 2));
643
814
  JS_SetModuleExport(ctx, m, "applyOffer",
644
815
  JS_NewCFunction(ctx, ota_apply_offer, "applyOffer", 3));
816
+ JS_SetModuleExport(ctx, m, "parseConfig",
817
+ JS_NewCFunction(ctx, ota_parse_config, "parseConfig", 1));
818
+ JS_SetModuleExport(ctx, m, "applyConfig",
819
+ JS_NewCFunction(ctx, ota_apply_config, "applyConfig", 2));
820
+ JS_SetModuleExport(ctx, m, "configState",
821
+ JS_NewCFunction(ctx, ota_config_state, "configState", 0));
645
822
  return 0;
646
823
  }
647
824
 
@@ -650,7 +827,7 @@ int mik__ota_client_module_init(JSContext* ctx, JSModuleDef* m) {
650
827
  JSModuleDef* mik__ota_client_init(JSContext* ctx) {
651
828
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
652
829
  CHECK_NOT_NULL(mik_rt);
653
- mik__ota_client_slot = MIK_AllocModuleSlot(mik_rt);
830
+ if (mik__ota_client_slot < 0) mik__ota_client_slot = MIK_ReserveModuleSlot();
654
831
 
655
832
  /* The bytecode version needs a context to derive (JS_WriteObject's first
656
833
  * byte), so it is read here and handed to the env, which has none. */
@@ -689,6 +866,9 @@ JSModuleDef* mik__ota_client_init(JSContext* ctx) {
689
866
  JS_AddModuleExport(ctx, m, "registry");
690
867
  JS_AddModuleExport(ctx, m, "parseOffer");
691
868
  JS_AddModuleExport(ctx, m, "applyOffer");
869
+ JS_AddModuleExport(ctx, m, "parseConfig");
870
+ JS_AddModuleExport(ctx, m, "applyConfig");
871
+ JS_AddModuleExport(ctx, m, "configState");
692
872
  return m;
693
873
  }
694
874
 
@@ -464,8 +464,7 @@ static int mik__pwm_module_init(JSContext* ctx, JSModuleDef* m) {
464
464
  }
465
465
 
466
466
  static JSModuleDef* mik__pwm_init(JSContext* ctx) {
467
- MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
468
- mik__pwm_slot = MIK_AllocModuleSlot(mik_rt);
467
+ if (mik__pwm_slot < 0) mik__pwm_slot = MIK_ReserveModuleSlot();
469
468
 
470
469
  JSRuntime* rt = JS_GetRuntime(ctx);
471
470
 
@@ -196,7 +196,7 @@ static int mik__sntp_module_init(JSContext* ctx, JSModuleDef* m) {
196
196
  static JSModuleDef* mik__sntp_init(JSContext* ctx) {
197
197
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
198
198
  CHECK_NOT_NULL(mik_rt);
199
- mik__sntp_slot = MIK_AllocModuleSlot(mik_rt);
199
+ if (mik__sntp_slot < 0) mik__sntp_slot = MIK_ReserveModuleSlot();
200
200
 
201
201
  auto* state = new MIKSntpState();
202
202
  state->event_queue = xQueueCreate(4, sizeof(struct timeval));
@@ -465,7 +465,7 @@ static int mik__uart_module_init(JSContext* ctx, JSModuleDef* m) {
465
465
 
466
466
  static JSModuleDef* mik__uart_init(JSContext* ctx) {
467
467
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
468
- mik__uart_slot = MIK_AllocModuleSlot(mik_rt);
468
+ if (mik__uart_slot < 0) mik__uart_slot = MIK_ReserveModuleSlot();
469
469
 
470
470
  /* Allocate per-runtime slot data */
471
471
  auto* slot_data = static_cast<MIKUartSlot*>(calloc(1, sizeof(MIKUartSlot)));
@@ -1236,7 +1236,7 @@ static int mik__wifi_module_init(JSContext* ctx, JSModuleDef* m) {
1236
1236
  static JSModuleDef* mik__wifi_init(JSContext* ctx) {
1237
1237
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
1238
1238
  CHECK_NOT_NULL(mik_rt);
1239
- mik__wifi_slot = MIK_AllocModuleSlot(mik_rt);
1239
+ if (mik__wifi_slot < 0) mik__wifi_slot = MIK_ReserveModuleSlot();
1240
1240
 
1241
1241
  auto* state = new MIKWifiState();
1242
1242
  state->event_queue = xQueueCreate(8, sizeof(MIKWifiEvent));
@@ -473,3 +473,107 @@ TEST_CASE("cancel sets the pending cancelled flag", "[http]") {
473
473
  TEST_ASSERT_TRUE_MESSAGE(flagAfterCancel, "cancel sets the shared flag");
474
474
  TEST_ASSERT_EQUAL_MESSAGE(0, pendingCount, "pending dropped after cancellation settles");
475
475
  }
476
+
477
+ /* ── Native sink (OTA client) ─────────────────────────────────────── */
478
+
479
+ static int native_done_calls;
480
+ static int native_done_status;
481
+ static char native_done_error[64];
482
+
483
+ static void native_sink_done(void*, int status, const char* error_msg) {
484
+ native_done_calls++;
485
+ native_done_status = status;
486
+ snprintf(native_done_error, sizeof(native_done_error), "%s", error_msg ? error_msg : "");
487
+ }
488
+
489
+ /* Install a native pending whose deadline has already passed, the way
490
+ * mik__http_start_native would minus the background task. Ownership of
491
+ * `cancelled` transfers to the entry; mik__pending_drop frees it. */
492
+ static uint32_t seed_expired_native_pending() {
493
+ native_done_calls = 0;
494
+ native_done_status = -1;
495
+ native_done_error[0] = '\0';
496
+
497
+ MIKHttpState* state = mik__http(rt);
498
+ MIKHttpPending p = {};
499
+ p.id = state->next_id++;
500
+ p.cancelled = new std::atomic<bool>(false);
501
+ p.sink.done = native_sink_done;
502
+ p.deadline_us = 1; /* in the past for any running device */
503
+ state->pending[state->pending_count++] = p;
504
+ return p.id;
505
+ }
506
+
507
+ TEST_CASE("a timed-out native request stays tagged native", "[http]") {
508
+ setup();
509
+ ensure_http_initialized();
510
+
511
+ uint32_t id = seed_expired_native_pending();
512
+
513
+ mik__http_consume(ctx);
514
+ int callsAfterTimeout = native_done_calls;
515
+ int statusAfterTimeout = native_done_status;
516
+ char errorAfterTimeout[64];
517
+ snprintf(errorAfterTimeout, sizeof(errorAfterTimeout), "%s", native_done_error);
518
+ size_t countAfterTimeout = mik__http(rt)->pending_count;
519
+ /* The entry outlives the timeout waiting for the task's terminal message.
520
+ * sink.done is what marks it native; clearing it would send that message
521
+ * into the JS branch and onto promises this entry never created. */
522
+ bool stillTaggedNative = mik__http(rt)->pending[0].sink.done != nullptr;
523
+
524
+ push_error(id, "connection closed");
525
+ mik__http_consume(ctx);
526
+ int callsAfterTerminal = native_done_calls;
527
+ size_t countAfterTerminal = mik__http(rt)->pending_count;
528
+
529
+ teardown();
530
+
531
+ TEST_ASSERT_EQUAL_MESSAGE(1, callsAfterTimeout, "deadline sweep reports once");
532
+ TEST_ASSERT_EQUAL(0, statusAfterTimeout);
533
+ TEST_ASSERT_EQUAL_STRING("timeout", errorAfterTimeout);
534
+ TEST_ASSERT_EQUAL_MESSAGE(1, countAfterTimeout, "entry waits for the terminal message");
535
+ TEST_ASSERT_TRUE_MESSAGE(stillTaggedNative, "sink.done survives the timeout");
536
+ TEST_ASSERT_EQUAL_MESSAGE(1, callsAfterTerminal, "done is terminal, not repeated");
537
+ TEST_ASSERT_EQUAL_MESSAGE(0, countAfterTerminal, "terminal message drops the entry");
538
+ }
539
+
540
+ TEST_CASE("destroy skips promises on a timed-out native request", "[http]") {
541
+ setup();
542
+ ensure_http_initialized();
543
+
544
+ uint32_t id = seed_expired_native_pending();
545
+ mik__http_consume(ctx);
546
+
547
+ /* Leave the entry in place and let destroy drain the terminal message.
548
+ * It must take the native branch and free no promises: a native entry's
549
+ * headers_promise was never built. */
550
+ push_error(id, "connection closed");
551
+ int callsBeforeTeardown = native_done_calls;
552
+
553
+ teardown();
554
+
555
+ TEST_ASSERT_EQUAL(1, callsBeforeTeardown);
556
+ }
557
+
558
+ TEST_CASE("the http module slot is stable across runtimes", "[http]") {
559
+ setup();
560
+ ensure_http_initialized();
561
+ int slotInFirstRuntime = mik__http_slot;
562
+ teardown();
563
+
564
+ /* A second runtime must find http at the same index, and no other module
565
+ * may be handed that index here. */
566
+ setup();
567
+ int burned = MIK_ReserveModuleSlot();
568
+ ensure_http_initialized();
569
+ int slotInSecondRuntime = mik__http_slot;
570
+ MIKHttpState* state = mik__http(rt);
571
+ size_t pendingCount = state ? state->pending_count : 999;
572
+ teardown();
573
+
574
+ TEST_ASSERT_EQUAL_MESSAGE(slotInFirstRuntime, slotInSecondRuntime,
575
+ "the same module keeps the same slot");
576
+ TEST_ASSERT_NOT_EQUAL_MESSAGE(burned, slotInSecondRuntime,
577
+ "http must not share a slot with another module");
578
+ TEST_ASSERT_EQUAL_MESSAGE(0, pendingCount, "fresh state, not another module's bytes");
579
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/firmware",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
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/native": "0.18.1",
51
- "@mikrojs/quickjs": "0.18.1"
50
+ "@mikrojs/native": "0.18.2",
51
+ "@mikrojs/quickjs": "0.18.2"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.0.0"
Binary file
Binary file
Binary file
Binary file
Binary file
package/project.cmake CHANGED
@@ -11,9 +11,9 @@
11
11
 
12
12
  # ── Validate ESP-IDF version ─────────────────────────────────────────
13
13
  if(IDF_VERSION_MAJOR LESS 6 OR
14
- (IDF_VERSION_MAJOR EQUAL 6 AND IDF_VERSION_MINOR EQUAL 0 AND IDF_VERSION_PATCH LESS 1))
14
+ (IDF_VERSION_MAJOR EQUAL 6 AND IDF_VERSION_MINOR LESS 1))
15
15
  message(FATAL_ERROR
16
- "mikrojs requires ESP-IDF >= 6.0.1, found ${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR}.${IDF_VERSION_PATCH}. "
16
+ "mikrojs requires ESP-IDF >= 6.1, found ${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR}.${IDF_VERSION_PATCH}. "
17
17
  "Install a supported version via EIM: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/get-started/index.html"
18
18
  )
19
19
  endif()
@@ -185,7 +185,7 @@ CONFIG_MBEDTLS_ECP_DP_BP512R1_ENABLED=n
185
185
 
186
186
  # Use smaller cert bundle (common CAs only)
187
187
  CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y
188
- # Deliberate deviation from IDF 6.0.2, which defaults this to n to save ~700 B
188
+ # Deliberate deviation from IDF, which defaults this to n to save ~700 B
189
189
  # of heap. We need it: with n, esp_crt_bundle looks up only the top served
190
190
  # cert's issuer, so any chain that serves a cross-signed root fails outright.
191
191
  # That is ~2 in 5 real hosts, mostly Google Trust Services roots cross-signed
@@ -193,10 +193,5 @@ CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y
193
193
  # Device-confirmed against registry.npmjs.org (MBEDTLS_ERR_X509_FATAL_ERROR,
194
194
  # -0x3000). Neither cross-signing root ships in the CMN or the full bundle, so
195
195
  # bundle selection is not an escape either.
196
- #
197
- # The cost on 6.0.2 is a leak: ~215 B of issuer-name copies per successful
198
- # handshake, ~10 KB/day at a 30-minute check-in cadence. Fixed upstream by
199
- # 0d8ff68f8a47 and a1f1d9072900, landing in an upcoming release. Until our
200
- # IDF floor gets there, patch IDF rather than turning this off.
201
196
  CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_CROSS_SIGNED_VERIFY=y
202
197