@mikrojs/firmware 0.18.0 → 0.18.1

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.
@@ -69,6 +69,7 @@ idf_component_register(
69
69
  "${MIK_SRC_DIR}/mik_sys.cpp"
70
70
  "${MIK_SRC_DIR}/mik_text_encoding.cpp"
71
71
  "${MIK_SRC_DIR}/mik_cbor.cpp"
72
+ "${MIK_SRC_DIR}/mik_sys_codec.cpp"
72
73
  "${MIK_SRC_DIR}/mik_result.cpp"
73
74
  "${MIK_SRC_DIR}/mik_udp.cpp"
74
75
  "${MIK_SRC_DIR}/mik_observable.cpp"
@@ -97,6 +98,15 @@ idf_component_register(
97
98
  "mik_pwm.cpp"
98
99
  "mik_nvs_kv.cpp"
99
100
  "mik_ota.cpp"
101
+ # The OTA check-in client and config reader: policy, state machine and slot
102
+ # store in the portable library, platform seam and JS surface here.
103
+ "${MIK_SRC_DIR}/mik_ota_policy.cpp"
104
+ "${MIK_SRC_DIR}/mik_ota_slots.cpp"
105
+ "${MIK_SRC_DIR}/mik_ota_config.cpp"
106
+ "${MIK_SRC_DIR}/mik_ota_js_hooks.cpp"
107
+ "${MIK_SRC_DIR}/mik_ota_client.cpp"
108
+ "mik_ota_env.cpp"
109
+ "mik_ota_client_module.cpp"
100
110
  "mik_recovery.cpp"
101
111
  "mik_rtc.cpp"
102
112
  "mik_serial_io.cpp"
@@ -182,7 +192,8 @@ include("${MIK_BYTECODE_CMAKE}")
182
192
 
183
193
  # Force linker to include self-registering native modules
184
194
  set(_MIK_MODULES cbor pin i2c i2s spi http http_server wifi rtc nvs_kv sntp sleep neopixel pwm uart ota)
185
- set(_MIK_BYTECODE_MODULES abort cbor env result schema fs http/helpers http/request http/server i2c i2s kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi)
195
+ list(APPEND _MIK_MODULES ota_client)
196
+ set(_MIK_BYTECODE_MODULES abort cbor env result schema fs http/helpers http/request 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 wifi)
186
197
  if(CONFIG_BT_ENABLED)
187
198
  list(APPEND _MIK_MODULES ble)
188
199
  list(APPEND _MIK_BYTECODE_MODULES ble)
@@ -48,10 +48,30 @@ struct MIKHttpQueuedMsg {
48
48
  MIKHttpQueuedMsg* next;
49
49
  };
50
50
 
51
+ /* A C consumer of one request's messages, used when the requester is native
52
+ * code rather than JS — the OTA client. Set on a pending entry, it replaces the
53
+ * two promises entirely: nothing on that entry is ever handed to JS.
54
+ *
55
+ * Callbacks run from mik__http_consume, i.e. on the JS loop thread, never on the
56
+ * HTTP background task. `done` fires exactly once and is terminal. */
57
+ struct MIKHttpNativeSink {
58
+ void (*headers)(void* user_data, int status);
59
+ void (*data)(void* user_data, const uint8_t* data, size_t len);
60
+ void (*done)(void* user_data, int status, const char* error_msg);
61
+ void* user_data;
62
+ };
63
+
51
64
  struct MIKHttpPending {
52
65
  uint32_t id;
53
66
  std::atomic<bool>* cancelled;
54
67
  bool js_cancelled;
68
+ /* Native requests only: when this request stops being worth waiting for,
69
+ * in esp_timer microseconds. 0 means no deadline. */
70
+ int64_t deadline_us;
71
+
72
+ /* `done == nullptr` means this is a JS request and the promises below are
73
+ * live. Otherwise messages route to the sink and the promises are unused. */
74
+ MIKHttpNativeSink sink;
55
75
 
56
76
  MIKPromise headers_promise;
57
77
  bool headers_resolved;
@@ -63,6 +83,33 @@ struct MIKHttpPending {
63
83
  MIKHttpQueuedMsg* queue_tail;
64
84
  };
65
85
 
86
+ /* One native request, as the OTA client describes it. Strings and buffers are
87
+ * borrowed for the duration of the start call only; the module copies them. */
88
+ struct MIKHttpNativeRequest {
89
+ const char* url;
90
+ const char* method;
91
+ const char* const* header_keys;
92
+ const char* const* header_values;
93
+ size_t header_count;
94
+ const uint8_t* body;
95
+ size_t body_len;
96
+ /* Whole-request bound, milliseconds; 0 for none. The 10s socket timeout
97
+ * only bounds a single read, so a server that dribbles is otherwise
98
+ * unbounded and holds the task and its TLS session indefinitely. */
99
+ uint32_t timeout_ms;
100
+ };
101
+
102
+ /* Bring the transport up for a native consumer that never imports the JS module.
103
+ * Must be called before mik__http_start_native. Idempotent. */
104
+ void mik__http_ensure_native(struct JSContext* ctx);
105
+
106
+ /* Start a request whose messages go to `sink`. Returns the request id, or 0 when
107
+ * it could not be started. Defined in mik_http.cpp. */
108
+ uint32_t mik__http_start_native(struct MIKRuntime* rt, const MIKHttpNativeRequest* req,
109
+ const MIKHttpNativeSink* sink);
110
+ /* Abandon a native request. No further sink callbacks fire for it. */
111
+ void mik__http_cancel_native(struct MIKRuntime* rt, uint32_t id);
112
+
66
113
  /* Ceilings shared with the test harness. Must match the #defines in
67
114
  * mik_http.cpp. */
68
115
  #define MIK_HTTP_MAX_PENDING 4
@@ -0,0 +1,19 @@
1
+ /* Internal wiring for the native OTA client (mikro/ota/client backed by C).
2
+ * Not part of the public ESP API: only the OTA module and its env implementation
3
+ * include this. */
4
+ #pragma once
5
+
6
+ #include "mikrojs/ota_env.h"
7
+
8
+ /* Fill the install-op slots with mik_ota.cpp's staging machinery — the same
9
+ * cores the native:mikro/ota JS bindings call. */
10
+ void mik__ota_fill_install_ops(MIKOtaEnv* env);
11
+
12
+ /* The OTA environment for this runtime, fully populated (install ops, kv, HTTP,
13
+ * identity, clock). Valid until the runtime is freed. `rt` is needed because the
14
+ * HTTP seam borrows the http module's task and queue, which live per runtime.
15
+ *
16
+ * `bytecode_version` cannot be derived without a JSContext, so it is passed in
17
+ * from the module init that has one. */
18
+ struct MIKRuntime;
19
+ const MIKOtaEnv* mik__ota_env_for(struct MIKRuntime* rt, int bytecode_version);
@@ -223,26 +223,47 @@ bool mik__handle_config_command(MIKReplTransport* transport, uint8_t cmd_type,
223
223
  uint16_t val_len = vl[0] | (vl[1] << 8);
224
224
  consumed += 2;
225
225
 
226
- char value[512];
227
- if (val_len >= sizeof(value) || consumed + (uint32_t)val_len > payload_len) {
226
+ /* Sized to carry a 4 KiB config document plus its {version, doc}
227
+ * envelope (a config seed is the largest value this path sees);
228
+ * mirrored as KV_VALUE_MAX_BYTES in the CLI/sim protocol — keep
229
+ * the two in sync. Heap, not stack: the value and its CBOR blob
230
+ * together would not fit a task stack. */
231
+ constexpr uint32_t kValueMax = 4608;
232
+ if (val_len >= kValueMax || consumed + (uint32_t)val_len > payload_len) {
228
233
  mik__proto_drain(transport, payload_len - consumed);
229
234
  mik__proto_send_err(transport, "value too long");
230
235
  return true;
231
236
  }
232
- if (!mik__proto_read_exact(transport, value, val_len)) return false;
237
+ char* value = (char*)malloc((size_t)val_len + 1);
238
+ uint8_t* blob = (uint8_t*)malloc((size_t)val_len + 8);
239
+ if (value == NULL || blob == NULL) {
240
+ free(value);
241
+ free(blob);
242
+ mik__proto_drain(transport, payload_len - consumed);
243
+ mik__proto_send_err(transport, "out of memory");
244
+ return true;
245
+ }
246
+ if (!mik__proto_read_exact(transport, value, val_len)) {
247
+ free(value);
248
+ free(blob);
249
+ return false;
250
+ }
233
251
  value[val_len] = '\0';
234
252
  consumed += val_len;
235
253
  if (payload_len > consumed) mik__proto_drain(transport, payload_len - consumed);
236
254
 
237
255
  if (key_len == 0 || key_len > 15) {
256
+ free(value);
257
+ free(blob);
238
258
  mik__proto_send_err(transport, "key exceeds NVS 15-char limit");
239
259
  return true;
240
260
  }
241
261
 
242
262
  nanocbor_encoder_t enc;
243
- uint8_t blob[sizeof(value) + 4];
244
- nanocbor_encoder_init(&enc, blob, sizeof(blob));
263
+ nanocbor_encoder_init(&enc, blob, (size_t)val_len + 8);
245
264
  if (nanocbor_put_tstrn(&enc, value, val_len) < 0) {
265
+ free(value);
266
+ free(blob);
246
267
  mik__proto_send_err(transport, "cbor encode failed");
247
268
  return true;
248
269
  }
@@ -255,6 +276,8 @@ bool mik__handle_config_command(MIKReplTransport* transport, uint8_t cmd_type,
255
276
  if (err == ESP_OK) err = nvs_commit(handle);
256
277
  nvs_close(handle);
257
278
  }
279
+ free(value);
280
+ free(blob);
258
281
  if (err == ESP_OK) {
259
282
  mik__proto_send_ok(transport);
260
283
  } else {
@@ -3,9 +3,12 @@
3
3
 
4
4
  #include "esp_crt_bundle.h"
5
5
  #include "esp_event.h"
6
+ #include "esp_heap_caps.h"
6
7
  #include "esp_http_client.h"
7
8
  #include "esp_log.h"
8
9
  #include "esp_netif.h"
10
+ #include "esp_system.h"
11
+ #include "esp_timer.h"
9
12
  #include "freertos/FreeRTOS.h"
10
13
  #include "freertos/queue.h"
11
14
  #include "freertos/semphr.h"
@@ -162,17 +165,19 @@ static bool mik__task_cancelled(MIKHttpTaskArgs* args) {
162
165
  * fragmentation that is NOT a leak in this code — it's mbedTLS scatter-alloc
163
166
  * during handshake plus lwIP holding socket TCBs in TIME_WAIT for 2*MSL
164
167
  * (CONFIG_LWIP_TCP_MSL). Both reclaim over time; sysFree recovers after the
165
- * TIME_WAIT window. The UAF fix in commit [UAF fix] removed the real bug;
166
- * remaining drift is expected platform behavior, not worth chasing without
167
- * a broader evaluation of CONFIG_MBEDTLS_DYNAMIC_BUFFER, session tickets,
168
- * or moving to a custom esp-tls-direct HTTP implementation. */
168
+ * TIME_WAIT window. The UAF fix in commit [UAF fix] removed the real bug.
169
+ * CONFIG_MBEDTLS_DYNAMIC_BUFFER and a shorter MSL are now both in
170
+ * sdkconfig.defaults.
171
+ * Still unexplored: session reuse, or an esp-tls-direct implementation. */
169
172
  static void mik__http_task(void* arg) {
170
173
  auto* args = static_cast<MIKHttpTaskArgs*>(arg);
171
174
 
172
175
  MIKHttpEventData event_data = {};
173
176
  bool headers_posted = false;
174
177
  bool cancelled = false;
175
- char error_buf[256] = {0};
178
+ /* 384: a build URL (64-hex checksum path) plus full TLS detail and heap
179
+ * figures overruns 256, and truncation eats the trailing heap figures. */
180
+ char error_buf[384] = {0};
176
181
  bool have_error = false;
177
182
 
178
183
  esp_http_client_config_t config = {};
@@ -188,6 +193,13 @@ static void mik__http_task(void* arg) {
188
193
  config.event_handler = mik__http_event_handler;
189
194
  config.user_data = &event_data;
190
195
  config.crt_bundle_attach = esp_crt_bundle_attach;
196
+ // No HTTP_TLS_DYN_BUF_RX_STATIC: converting the RX buffer to a static
197
+ // ~16.6 KB allocation right after the handshake demands a contiguous
198
+ // block exactly when the heap is most fragmented, and the failure is
199
+ // silent (esp-tls records no error code and its log line compiles out) —
200
+ // observed live on a c6 as a connect failure with empty TLS slots. For
201
+ // check-in-sized exchanges dynamic RX allocates per record (~2 KB), which
202
+ // is strictly lighter than pinning the full buffer.
191
203
 
192
204
  esp_http_client_handle_t client = esp_http_client_init(&config);
193
205
  if (!client) {
@@ -225,12 +237,18 @@ static void mik__http_task(void* arg) {
225
237
  * keep the plain connect error (no heap-level guessing). */
226
238
  bool out_of_memory = err == ESP_ERR_NO_MEM || last_tls_err == ESP_ERR_NO_MEM ||
227
239
  tls_code == 0x008d || tls_code == 0x7f00;
240
+ /* Heap figures settle ambiguous codes: -0x3000 (X509 fatal) is
241
+ * both "CA not in bundle" and "calloc failed in the bundle verify
242
+ * callback" — a starved largest block names the second. */
228
243
  snprintf(error_buf, sizeof(error_buf),
229
244
  "fetch failed: %s %s (%s, errno=%d, "
230
- "esp_tls=%s, mbedtls=-0x%04x, flags=0x%x)",
245
+ "esp_tls=%s, mbedtls=-0x%04x, flags=0x%x, "
246
+ "sysFree=%u, largestBlock=%u)",
231
247
  out_of_memory ? "out of memory connecting to" : "could not connect to",
232
248
  args->req.url, esp_err_to_name(err), sock_errno,
233
- esp_err_to_name(last_tls_err), tls_code, tls_flags);
249
+ esp_err_to_name(last_tls_err), tls_code, tls_flags,
250
+ static_cast<unsigned>(esp_get_free_heap_size()),
251
+ static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)));
234
252
  have_error = true;
235
253
  goto cleanup;
236
254
  }
@@ -476,6 +494,104 @@ static bool mik__enqueue_msg(MIKHttpPending* p, const MIKHttpMsg& m) {
476
494
  return true;
477
495
  }
478
496
 
497
+ /* ── Native requests ───────────────────────────────────────────────── */
498
+ /* Native code (the OTA client) shares this module's task, TLS setup, inflight
499
+ * ceiling and chunk budget rather than standing up a second esp_http_client
500
+ * path: one TLS client on the device is what keeps the handshake heap spike
501
+ * bounded and tuned in one place. */
502
+
503
+ uint32_t mik__http_start_native(MIKRuntime* rt, const MIKHttpNativeRequest* spec,
504
+ const MIKHttpNativeSink* sink) {
505
+ if (!rt || !spec || !sink || !sink->done) return 0;
506
+ if (mik__http_slot < 0 || !mik__http_st(rt)) return 0;
507
+ MIKHttpState* state = mik__http_st(rt);
508
+ if (state->pending_count >= MIK_HTTP_MAX_PENDING) return 0;
509
+
510
+ MIKHttpRequest req = {};
511
+ req.url = strdup(spec->url ? spec->url : "");
512
+ if (!req.url) return 0;
513
+ req.method = mik__method_from_string(spec->method);
514
+
515
+ if (spec->body && spec->body_len > 0) {
516
+ req.body = static_cast<uint8_t*>(malloc(spec->body_len));
517
+ if (!req.body) {
518
+ mik__http_free_request(&req);
519
+ return 0;
520
+ }
521
+ memcpy(req.body, spec->body, spec->body_len);
522
+ req.body_len = spec->body_len;
523
+ }
524
+
525
+ if (spec->header_count > 0) {
526
+ req.headers =
527
+ static_cast<MIKHttpHeader*>(calloc(spec->header_count, sizeof(MIKHttpHeader)));
528
+ if (!req.headers) {
529
+ mik__http_free_request(&req);
530
+ return 0;
531
+ }
532
+ for (size_t i = 0; i < spec->header_count; i++) {
533
+ char* key = strdup(spec->header_keys[i]);
534
+ char* value = strdup(spec->header_values[i]);
535
+ if (!key || !value) {
536
+ free(key);
537
+ free(value);
538
+ mik__http_free_request(&req);
539
+ return 0;
540
+ }
541
+ req.headers[req.header_count].key = key;
542
+ req.headers[req.header_count].value = value;
543
+ req.header_count++;
544
+ }
545
+ }
546
+
547
+ MIKHttpPending pending = {};
548
+ pending.id = state->next_id++;
549
+ pending.sink = *sink;
550
+ pending.deadline_us =
551
+ spec->timeout_ms > 0
552
+ ? esp_timer_get_time() + static_cast<int64_t>(spec->timeout_ms) * 1000
553
+ : 0;
554
+
555
+ auto* cancelled = new std::atomic<bool>(false);
556
+ pending.cancelled = cancelled;
557
+
558
+ auto* task_args = static_cast<MIKHttpTaskArgs*>(malloc(sizeof(MIKHttpTaskArgs)));
559
+ if (!task_args) {
560
+ mik__http_free_request(&req);
561
+ delete cancelled;
562
+ return 0;
563
+ }
564
+ task_args->id = pending.id;
565
+ task_args->req = req;
566
+ task_args->result_queue = state->result_queue;
567
+ task_args->inflight = state->inflight;
568
+ task_args->cancelled = cancelled;
569
+
570
+ if (xTaskCreate(mik__http_task, "mik_http", MIK_HTTP_TASK_STACK_SIZE, task_args,
571
+ tskIDLE_PRIORITY + 1, nullptr) != pdPASS) {
572
+ mik__http_free_request(&task_args->req);
573
+ free(task_args);
574
+ delete cancelled;
575
+ return 0;
576
+ }
577
+
578
+ state->pending[state->pending_count++] = pending;
579
+ return pending.id;
580
+ }
581
+
582
+ void mik__http_cancel_native(MIKRuntime* rt, uint32_t id) {
583
+ if (!rt || mik__http_slot < 0 || !mik__http_st(rt)) return;
584
+ MIKHttpState* state = mik__http_st(rt);
585
+ MIKHttpPending* p = mik__find_pending(state, id);
586
+ if (!p || !p->sink.done) return;
587
+ if (p->cancelled) p->cancelled->store(true, std::memory_order_relaxed);
588
+ /* Stop delivering entirely: the task still emits a terminal message, which
589
+ * the consume loop then drops along with the pending entry. */
590
+ p->sink.headers = nullptr;
591
+ p->sink.data = nullptr;
592
+ p->js_cancelled = true;
593
+ }
594
+
479
595
  static JSValue mik__http_request(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
480
596
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
481
597
  CHECK_NOT_NULL(mik_rt);
@@ -774,6 +890,18 @@ static void mik__ensure_netif_initialized() {
774
890
  }
775
891
  }
776
892
 
893
+ /* Bring the transport up for a native consumer — the OTA client, which uses this
894
+ * module's task and queue from C rather than importing it from JS.
895
+ *
896
+ * Without this the module is only ever initialized by an import, so a firmware
897
+ * whose only HTTP user is native would have no slot, no state and no loop
898
+ * consumer: every request would fail to start and no response would ever be
899
+ * drained. Idempotent, and safe either side of a JS import — the loop consumer
900
+ * registration de-duplicates. */
901
+ void mik__http_ensure_native(JSContext* ctx);
902
+ void mik__http_consume(JSContext* ctx);
903
+ void mik__http_destroy(JSContext* ctx);
904
+
777
905
  static void mik__http_ensure_initialized(JSContext* ctx) {
778
906
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
779
907
  CHECK_NOT_NULL(mik_rt);
@@ -792,6 +920,14 @@ static void mik__http_ensure_initialized(JSContext* ctx) {
792
920
  mik__http_st(mik_rt) = state;
793
921
  }
794
922
 
923
+ void mik__http_ensure_native(JSContext* ctx) {
924
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
925
+ if (!mik_rt) return;
926
+ if (mik__http_slot < 0) mik__http_slot = MIK_AllocModuleSlot(mik_rt);
927
+ mik__http_ensure_initialized(ctx);
928
+ MIK_RegisterLoopConsumer(mik_rt, mik__http_consume, mik__http_destroy);
929
+ }
930
+
795
931
  static int mik__http_module_init(JSContext* ctx, JSModuleDef* m) {
796
932
  mik__http_ensure_initialized(ctx);
797
933
  JS_SetModuleExport(ctx, m, "request",
@@ -806,7 +942,9 @@ static int mik__http_module_init(JSContext* ctx, JSModuleDef* m) {
806
942
 
807
943
  static JSModuleDef* mik__http_init(JSContext* ctx) {
808
944
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
809
- mik__http_slot = MIK_AllocModuleSlot(mik_rt);
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);
810
948
 
811
949
  JSModuleDef* m = JS_NewCModule(ctx, "native:mikro/http", mik__http_module_init);
812
950
  if (!m) return nullptr;
@@ -825,6 +963,26 @@ void mik__http_consume(JSContext* ctx) {
825
963
  if (!mik__http_st(mik_rt)) return;
826
964
 
827
965
  MIKHttpState* state = mik__http_st(mik_rt);
966
+
967
+ /* Time out native requests before draining, so a request whose deadline
968
+ * passed reports it even when the transport has gone quiet. The socket
969
+ * timeout bounds one read; this bounds the request. */
970
+ int64_t now_us = esp_timer_get_time();
971
+ for (size_t i = 0; i < state->pending_count; i++) {
972
+ MIKHttpPending* p = &state->pending[i];
973
+ if (!p->sink.done || p->deadline_us == 0 || now_us < p->deadline_us) continue;
974
+ MIKHttpNativeSink sink = p->sink;
975
+ /* Stop delivering and let the task's terminal message drop the entry;
976
+ * the sink hears about it once, here. */
977
+ p->sink.headers = nullptr;
978
+ p->sink.data = nullptr;
979
+ p->sink.done = nullptr;
980
+ p->js_cancelled = true;
981
+ p->deadline_us = 0;
982
+ if (p->cancelled) p->cancelled->store(true, std::memory_order_relaxed);
983
+ sink.done(sink.user_data, 0, "timeout");
984
+ }
985
+
828
986
  MIKHttpMsg msg;
829
987
  while (xQueueReceive(state->result_queue, &msg, 0) == pdTRUE) {
830
988
  MIKHttpPending* p = mik__find_pending(state, msg.id);
@@ -841,6 +999,38 @@ void mik__http_consume(JSContext* ctx) {
841
999
  continue;
842
1000
  }
843
1001
 
1002
+ if (p->sink.done) {
1003
+ /* Native consumer: hand the message over and free the payload here,
1004
+ * since nothing is queued for a later JS read. */
1005
+ size_t idx = static_cast<size_t>(p - state->pending);
1006
+ if (msg.kind == MIK_HTTP_MSG_HEADERS) {
1007
+ if (p->sink.headers) p->sink.headers(p->sink.user_data, msg.status);
1008
+ mik__http_free_headers(msg.headers, msg.header_count);
1009
+ } else if (msg.kind == MIK_HTTP_MSG_CHUNK) {
1010
+ if (p->sink.data) p->sink.data(p->sink.user_data, msg.chunk_data, msg.chunk_len);
1011
+ xSemaphoreGive(state->inflight);
1012
+ free(msg.chunk_data);
1013
+ } else {
1014
+ const char* error = msg.kind == MIK_HTTP_MSG_ERROR
1015
+ ? (msg.error_message ? msg.error_message : "HTTP error")
1016
+ : nullptr;
1017
+ /* A cancelled request never calls back, whatever the task ended
1018
+ * up reporting. The consumer cancels on its way out, so calling
1019
+ * `done` here would reach a destroyed object. */
1020
+ if (!p->js_cancelled) {
1021
+ MIKHttpNativeSink sink = p->sink;
1022
+ /* Drop first: the callback may start the next request, and
1023
+ * that would reuse this slot. */
1024
+ mik__pending_drop(state, idx);
1025
+ sink.done(sink.user_data, msg.status, error);
1026
+ } else {
1027
+ mik__pending_drop(state, idx);
1028
+ }
1029
+ if (msg.kind == MIK_HTTP_MSG_ERROR) free(msg.error_message);
1030
+ }
1031
+ continue;
1032
+ }
1033
+
844
1034
  if (msg.kind == MIK_HTTP_MSG_HEADERS) {
845
1035
  JSValue v =
846
1036
  mik__headers_result_ok(ctx, msg.status, msg.headers, msg.header_count);
@@ -966,6 +1156,7 @@ void mik__http_destroy(JSContext* ctx) {
966
1156
  mik__free_queued_msg(m);
967
1157
  m = next;
968
1158
  }
1159
+ if (p->sink.done) continue; /* native pending: no promises to free */
969
1160
  if (!p->headers_resolved) MIK_FreePromise(ctx, &p->headers_promise);
970
1161
  if (p->next_promise_active) MIK_FreePromise(ctx, &p->next_promise);
971
1162
  }
@@ -107,20 +107,36 @@ static JSValue mik__nvs_kv_get(JSContext* ctx, JSValue this_val, int argc, JSVal
107
107
  return JS_UNDEFINED;
108
108
  }
109
109
 
110
+ /* Absence is the only silent outcome: a missing namespace (nothing ever
111
+ * stored) or a missing key reads as undefined. Every other failure throws,
112
+ * so a read starved by heap pressure (nvs_open allocates its handle) is
113
+ * never mistaken for "not stored". */
110
114
  nvs_handle_t handle;
111
- if (nvs_open(mik__nvs_ns(magic), NVS_READONLY, &handle) != ESP_OK) {
115
+ esp_err_t err = nvs_open(mik__nvs_ns(magic), NVS_READONLY, &handle);
116
+ if (err == ESP_ERR_NVS_NOT_FOUND) {
112
117
  JS_FreeCString(ctx, key);
113
118
  return JS_UNDEFINED;
114
119
  }
120
+ if (err != ESP_OK) {
121
+ JS_ThrowPlainError(ctx, "nvs open failed reading \"%s\": %s", key, esp_err_to_name(err));
122
+ JS_FreeCString(ctx, key);
123
+ return JS_EXCEPTION;
124
+ }
115
125
 
116
126
  /* Query size first */
117
127
  size_t len = 0;
118
- esp_err_t err = nvs_get_blob(handle, key, nullptr, &len);
119
- if (err != ESP_OK || len == 0) {
128
+ err = nvs_get_blob(handle, key, nullptr, &len);
129
+ if (err == ESP_ERR_NVS_NOT_FOUND || (err == ESP_OK && len == 0)) {
120
130
  JS_FreeCString(ctx, key);
121
131
  nvs_close(handle);
122
132
  return JS_UNDEFINED;
123
133
  }
134
+ if (err != ESP_OK) {
135
+ JS_ThrowPlainError(ctx, "nvs read failed for \"%s\": %s", key, esp_err_to_name(err));
136
+ JS_FreeCString(ctx, key);
137
+ nvs_close(handle);
138
+ return JS_EXCEPTION;
139
+ }
124
140
 
125
141
  /* Read blob */
126
142
  auto* buf = static_cast<uint8_t*>(js_malloc(ctx, len));
@@ -136,7 +152,7 @@ static JSValue mik__nvs_kv_get(JSContext* ctx, JSValue this_val, int argc, JSVal
136
152
 
137
153
  if (err != ESP_OK) {
138
154
  js_free(ctx, buf);
139
- return JS_UNDEFINED;
155
+ return JS_ThrowPlainError(ctx, "nvs read failed: %s", esp_err_to_name(err));
140
156
  }
141
157
 
142
158
  /* Decode CBOR */
@@ -145,7 +161,14 @@ static JSValue mik__nvs_kv_get(JSContext* ctx, JSValue this_val, int argc, JSVal
145
161
  JSValue result = mik__cbor_decode_value(ctx, &decoder, 0);
146
162
  js_free(ctx, buf);
147
163
 
148
- if (JS_IsException(result)) return JS_UNDEFINED;
164
+ /* A stored blob that does not decode is corruption, not absence. The
165
+ * decoder signals malformed input by value with no pending exception
166
+ * (only allocation failures inside it carry one). TypeError marks it as
167
+ * corruption: the kv layer deletes and heals on TypeError only, never on
168
+ * the transient (plain Error) failures above. */
169
+ if (JS_IsException(result) && !JS_HasException(ctx)) {
170
+ return JS_ThrowTypeError(ctx, "stored nvs value is not valid CBOR");
171
+ }
149
172
  return result;
150
173
  }
151
174