@mikrojs/firmware 0.18.0 → 0.18.2-next.20260826235606

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.
Files changed (30) hide show
  1. package/components/mikrojs/CMakeLists.txt +12 -1
  2. package/components/mikrojs/include/mik_http_internal.h +47 -0
  3. package/components/mikrojs/include/mik_ota_native.h +19 -0
  4. package/components/mikrojs/mik_ble.cpp +1 -1
  5. package/components/mikrojs/mik_config.cpp +28 -5
  6. package/components/mikrojs/mik_http.cpp +201 -9
  7. package/components/mikrojs/mik_http_server.cpp +1 -2
  8. package/components/mikrojs/mik_i2s.cpp +1 -1
  9. package/components/mikrojs/mik_nvs_kv.cpp +28 -5
  10. package/components/mikrojs/mik_ota.cpp +305 -106
  11. package/components/mikrojs/mik_ota_client_module.cpp +923 -0
  12. package/components/mikrojs/mik_ota_env.cpp +370 -0
  13. package/components/mikrojs/mik_pwm.cpp +1 -2
  14. package/components/mikrojs/mik_sntp.cpp +1 -1
  15. package/components/mikrojs/mik_uart.cpp +1 -1
  16. package/components/mikrojs/mik_wifi.cpp +1 -1
  17. package/components/mikrojs/test/http_test.cpp +104 -0
  18. package/components/mikrojs/test/ota_host/run.sh +7 -2
  19. package/package.json +3 -3
  20. package/prebuilds/esp32/bootloader/bootloader.bin +0 -0
  21. package/prebuilds/esp32/mikrojs.bin +0 -0
  22. package/prebuilds/esp32c3/bootloader/bootloader.bin +0 -0
  23. package/prebuilds/esp32c3/mikrojs.bin +0 -0
  24. package/prebuilds/esp32c5/bootloader/bootloader.bin +0 -0
  25. package/prebuilds/esp32c5/mikrojs.bin +0 -0
  26. package/prebuilds/esp32c6/bootloader/bootloader.bin +0 -0
  27. package/prebuilds/esp32c6/mikrojs.bin +0 -0
  28. package/prebuilds/esp32s3/bootloader/bootloader.bin +0 -0
  29. package/prebuilds/esp32s3/mikrojs.bin +0 -0
  30. package/sdkconfig.defaults +58 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * MIKOtaEnv for ESP-IDF: the platform side of the native OTA client.
3
+ *
4
+ * Every seam here is deliberately thin. The parts that are easy to get subtly
5
+ * wrong — how a mik.sys value is encoded, how the device-name pair is spelled —
6
+ * live in the portable library behind host tests (mikrojs/sys_codec.h), because
7
+ * getting them wrong means the C and JS implementations disagree about live
8
+ * device state in a way no compile catches.
9
+ */
10
+
11
+ #include <esp_app_desc.h>
12
+ #include <esp_random.h>
13
+ #include <esp_system.h>
14
+ #include <esp_timer.h>
15
+ #include <nvs.h>
16
+
17
+ #include <cstdio>
18
+ #include <cstring>
19
+
20
+ #include "esp_log.h"
21
+ #include "mik_http_internal.h"
22
+ #include "mik_ota_native.h"
23
+ #include "mikrojs/mikrojs.h"
24
+ #include "mikrojs/private.h"
25
+ #include "mikrojs/platform.h"
26
+ #include "mikrojs/sys_codec.h"
27
+
28
+ #define MIK_OTA_ENV_TAG "native:mikro/ota_client"
29
+ #define MIK_OTA_SYS_NS "mik.sys"
30
+
31
+ namespace {
32
+
33
+ /* One env per runtime. The OTA client is a singleton within a runtime (one
34
+ * check-in loop per device), so a file-scope record is the whole lifetime story. */
35
+ struct EnvState {
36
+ MIKOtaEnv env;
37
+ MIKRuntime* rt;
38
+ int bytecode_version;
39
+ /* The device id string the platform owns; copied so identity() can hand back
40
+ * a fixed-size field without worrying about the platform's lifetime. */
41
+ char device_id[64];
42
+ };
43
+
44
+ EnvState g_state = {};
45
+
46
+ // ── kv (mik.sys) ─────────────────────────────────────────────────────────────
47
+ // Values are the CBOR encoding of the value itself, exactly as
48
+ // native:mikro/nvs_kv writes them, so `ota.tries` means the same thing whether
49
+ // the C policy or the JS one reads it.
50
+
51
+ /* Absence is the only silent outcome: a missing namespace (nothing ever stored)
52
+ * or a missing key reads as absent. Every other failure is an error, so a read
53
+ * starved by heap pressure — nvs_open allocates its handle — is never mistaken
54
+ * for "not stored". Same rule as native:mikro/nvs_kv's get. */
55
+ MIKOtaKvStatus kv_read_blob(const char* key, uint8_t* out, size_t* inout_len) {
56
+ nvs_handle_t h;
57
+ esp_err_t err = nvs_open(MIK_OTA_SYS_NS, NVS_READONLY, &h);
58
+ if (err == ESP_ERR_NVS_NOT_FOUND) return MIK_OTA_KV_ABSENT;
59
+ if (err != ESP_OK) return MIK_OTA_KV_ERROR;
60
+
61
+ size_t len = 0;
62
+ err = nvs_get_blob(h, key, nullptr, &len);
63
+ if (err == ESP_ERR_NVS_NOT_FOUND) {
64
+ nvs_close(h);
65
+ return MIK_OTA_KV_ABSENT;
66
+ }
67
+ if (err != ESP_OK) {
68
+ nvs_close(h);
69
+ return MIK_OTA_KV_ERROR;
70
+ }
71
+ if (len == 0) {
72
+ nvs_close(h);
73
+ return MIK_OTA_KV_ABSENT;
74
+ }
75
+ if (!out) {
76
+ *inout_len = len;
77
+ nvs_close(h);
78
+ return MIK_OTA_KV_OK;
79
+ }
80
+ if (*inout_len < len) {
81
+ nvs_close(h);
82
+ return MIK_OTA_KV_ERROR;
83
+ }
84
+ err = nvs_get_blob(h, key, out, &len);
85
+ nvs_close(h);
86
+ if (err != ESP_OK) return MIK_OTA_KV_ERROR;
87
+ *inout_len = len;
88
+ return MIK_OTA_KV_OK;
89
+ }
90
+
91
+ bool kv_write_blob(const char* key, const uint8_t* data, size_t len) {
92
+ nvs_handle_t h;
93
+ if (nvs_open(MIK_OTA_SYS_NS, NVS_READWRITE, &h) != ESP_OK) return false;
94
+ esp_err_t err = nvs_set_blob(h, key, data, len);
95
+ if (err == ESP_OK) err = nvs_commit(h);
96
+ nvs_close(h);
97
+ return err == ESP_OK;
98
+ }
99
+
100
+ MIKOtaKvStatus env_kv_get_blob(void*, const char* key, uint8_t* out, size_t* inout_len) {
101
+ return kv_read_blob(key, out, inout_len);
102
+ }
103
+
104
+ bool env_kv_set_blob(void*, const char* key, const uint8_t* data, size_t len) {
105
+ return kv_write_blob(key, data, len);
106
+ }
107
+
108
+ bool env_kv_get_str(void*, const char* key, char* out, size_t max_len) {
109
+ uint8_t buf[320];
110
+ size_t len = sizeof(buf);
111
+ if (kv_read_blob(key, buf, &len) != MIK_OTA_KV_OK) return false;
112
+ return mik__kv_decode_str(buf, len, out, max_len);
113
+ }
114
+
115
+ bool env_kv_set_str(void*, const char* key, const char* value) {
116
+ uint8_t buf[320];
117
+ size_t needed = mik__kv_encode_str(value, buf, sizeof(buf));
118
+ if (needed > sizeof(buf)) return false;
119
+ return kv_write_blob(key, buf, needed);
120
+ }
121
+
122
+ bool env_kv_get_i32(void*, const char* key, int32_t* out) {
123
+ uint8_t buf[16];
124
+ size_t len = sizeof(buf);
125
+ if (kv_read_blob(key, buf, &len) != MIK_OTA_KV_OK) return false;
126
+ return mik__kv_decode_i32(buf, len, out);
127
+ }
128
+
129
+ bool env_kv_set_i32(void*, const char* key, int32_t value) {
130
+ uint8_t buf[16];
131
+ size_t needed = mik__kv_encode_i32(value, buf, sizeof(buf));
132
+ if (needed > sizeof(buf)) return false;
133
+ return kv_write_blob(key, buf, needed);
134
+ }
135
+
136
+ bool env_kv_remove(void*, const char* key) {
137
+ nvs_handle_t h;
138
+ if (nvs_open(MIK_OTA_SYS_NS, NVS_READWRITE, &h) != ESP_OK) return false;
139
+ esp_err_t err = nvs_erase_key(h, key);
140
+ if (err == ESP_OK) err = nvs_commit(h);
141
+ nvs_close(h);
142
+ /* A key that was never there is not a failure: the client removes slots
143
+ * unconditionally. */
144
+ return err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND;
145
+ }
146
+
147
+ // ── HTTP ─────────────────────────────────────────────────────────────────────
148
+ // Borrowed from native:mikro/http: its task, TLS setup, inflight ceiling and
149
+ // chunk budget. A second esp_http_client path would double the handshake heap
150
+ // spike this device has the least of.
151
+
152
+ void* env_http_request(void* opaque, const MIKOtaHttpRequest* req,
153
+ const MIKOtaHttpCallbacks* cbs) {
154
+ auto* state = static_cast<EnvState*>(opaque);
155
+ MIKHttpNativeRequest spec = {};
156
+ spec.url = req->url;
157
+ spec.method = req->method;
158
+ spec.header_keys = req->header_keys;
159
+ spec.header_values = req->header_values;
160
+ spec.header_count = req->header_count;
161
+ spec.body = req->body;
162
+ spec.body_len = req->body_len;
163
+ spec.timeout_ms = req->timeout_ms;
164
+
165
+ MIKHttpNativeSink sink = {};
166
+ sink.headers = cbs->headers;
167
+ sink.data = cbs->data;
168
+ sink.done = cbs->done;
169
+ sink.user_data = cbs->user_data;
170
+
171
+ uint32_t id = mik__http_start_native(state->rt, &spec, &sink);
172
+ if (id == 0) return nullptr;
173
+ /* The handle is the id, offset so it is never NULL for id 0's sake. */
174
+ return reinterpret_cast<void*>(static_cast<uintptr_t>(id));
175
+ }
176
+
177
+ void env_http_cancel(void* opaque, void* handle) {
178
+ auto* state = static_cast<EnvState*>(opaque);
179
+ mik__http_cancel_native(state->rt, static_cast<uint32_t>(reinterpret_cast<uintptr_t>(handle)));
180
+ }
181
+
182
+ // ── identity and system ──────────────────────────────────────────────────────
183
+
184
+ bool env_identity(void* opaque, MIKDeviceIdentity* out) {
185
+ auto* state = static_cast<EnvState*>(opaque);
186
+ if (!out) return false;
187
+ *out = {};
188
+ snprintf(out->device_id, sizeof(out->device_id), "%s", state->device_id);
189
+ #ifdef MIK_FW_VERSION
190
+ snprintf(out->firmware_version, sizeof(out->firmware_version), "%s", MIK_FW_VERSION);
191
+ #else
192
+ snprintf(out->firmware_version, sizeof(out->firmware_version), "0.0.0-dev");
193
+ #endif
194
+ const esp_app_desc_t* desc = esp_app_get_description();
195
+ for (int i = 0; i < 32; i++) {
196
+ snprintf(out->firmware_hash + i * 2, 3, "%02x", desc->app_elf_sha256[i]);
197
+ }
198
+ out->bytecode_version = state->bytecode_version;
199
+ return true;
200
+ }
201
+
202
+ bool env_storage_free(void*, size_t* out) {
203
+ const MIKPlatform* platform = MIK_GetPlatform();
204
+ size_t total = 0;
205
+ size_t used = 0;
206
+ if (!platform->get_fs_info || !platform->get_fs_info("user", &total, &used)) return false;
207
+ if (out) *out = total > used ? total - used : 0;
208
+ return true;
209
+ }
210
+
211
+ bool env_get_device_name(void*, int* out_rev, char* out_name, size_t name_len) {
212
+ const MIKPlatform* platform = MIK_GetPlatform();
213
+ const char* stored = platform->get_device_name ? platform->get_device_name() : nullptr;
214
+ if (!stored) return false;
215
+ /* A pair that will not parse reads as never named, matching the JS reader. */
216
+ return mik__device_name_parse(stored, out_rev, out_name, name_len);
217
+ }
218
+
219
+ void env_set_device_name(void*, int rev, const char* name) {
220
+ const MIKPlatform* platform = MIK_GetPlatform();
221
+ if (!platform->set_device_name) return;
222
+ char text[160];
223
+ size_t needed = mik__device_name_format(rev, name, text, sizeof(text));
224
+ if (needed >= sizeof(text)) return; /* an absurd name: leave the pair alone */
225
+ platform->set_device_name(text);
226
+ }
227
+
228
+ void env_restart(void*) {
229
+ const MIKPlatform* platform = MIK_GetPlatform();
230
+ if (platform->restart) platform->restart();
231
+ }
232
+
233
+ int64_t env_monotonic_ms(void*) { return esp_timer_get_time() / 1000; }
234
+
235
+ double env_random_fraction(void*) {
236
+ /* esp_random is uniform over the full 32-bit range; scale to [0, 1). */
237
+ return static_cast<double>(esp_random()) / 4294967296.0;
238
+ }
239
+
240
+ void env_log(void*, int level, const char* fmt, ...) {
241
+ va_list args;
242
+ va_start(args, fmt);
243
+ char buf[320];
244
+ vsnprintf(buf, sizeof(buf), fmt, args);
245
+ va_end(args);
246
+ switch (level) {
247
+ case MIK_LOG_ERROR:
248
+ ESP_LOGE(MIK_OTA_ENV_TAG, "%s", buf);
249
+ break;
250
+ case MIK_LOG_WARN:
251
+ ESP_LOGW(MIK_OTA_ENV_TAG, "%s", buf);
252
+ break;
253
+ case MIK_LOG_DEBUG:
254
+ ESP_LOGD(MIK_OTA_ENV_TAG, "%s", buf);
255
+ break;
256
+ default:
257
+ ESP_LOGI(MIK_OTA_ENV_TAG, "%s", buf);
258
+ break;
259
+ }
260
+ }
261
+
262
+ /* Join a JS-level app path onto the runtime's fs base, the way the fs layer
263
+ * does for `readFile('/app/...')`. */
264
+ void resolve_app_path(EnvState* state, const char* leaf, char* out, size_t out_len) {
265
+ const char* root = state && state->rt
266
+ ? (state->rt->fs_root ? state->rt->fs_root : state->rt->fs_base_path)
267
+ : nullptr;
268
+ snprintf(out, out_len, "%s%s", root ? root : "", leaf);
269
+ }
270
+
271
+ char* env_read_manifest(void* opaque) {
272
+ auto* state = static_cast<EnvState*>(opaque);
273
+ char path[128];
274
+ resolve_app_path(state, "/app/mikro.app.json", path, sizeof(path));
275
+ FILE* f = fopen(path, "r");
276
+ if (!f) return nullptr;
277
+ fseek(f, 0, SEEK_END);
278
+ long size = ftell(f);
279
+ /* A manifest is a few hundred bytes of metadata plus whatever configDefaults
280
+ * carries. The bound is generous but present: this is read into RAM. */
281
+ if (size <= 0 || size > 8192) {
282
+ fclose(f);
283
+ return nullptr;
284
+ }
285
+ rewind(f);
286
+ char* text = static_cast<char*>(malloc(static_cast<size_t>(size) + 1));
287
+ if (!text) {
288
+ fclose(f);
289
+ return nullptr;
290
+ }
291
+ size_t read = fread(text, 1, static_cast<size_t>(size), f);
292
+ fclose(f);
293
+ text[read] = '\0';
294
+ return text;
295
+ }
296
+
297
+ bool env_read_app_version(void* opaque, char* out, size_t out_len) {
298
+ auto* state = static_cast<EnvState*>(opaque);
299
+ /* The live app version, the same file `ota.ts` reads as /app/package.json.
300
+ * That is a JS-level path: the fs layer joins it onto the runtime's base
301
+ * path, so reaching it from C means joining it here too. Read with stdio
302
+ * rather than through the JS fs layer, because the client runs from C and
303
+ * must not need a JSContext for a report field. */
304
+ char path[128];
305
+ resolve_app_path(state, "/app/package.json", path, sizeof(path));
306
+ FILE* f = fopen(path, "r");
307
+ if (!f) return false;
308
+ /* First 511 bytes only: enough for the small package.json a packed app
309
+ * carries. A "version" key past that, or a nested one before the top-level
310
+ * key, misreports; keep version near the top of package.json. */
311
+ char buf[512];
312
+ size_t read = fread(buf, 1, sizeof(buf) - 1, f);
313
+ fclose(f);
314
+ buf[read] = '\0';
315
+
316
+ /* Minimal scan for the top-level "version" string. A full JSON parse would
317
+ * mean pulling one in for a single field. */
318
+ const char* key = strstr(buf, "\"version\"");
319
+ if (!key) return false;
320
+ const char* colon = strchr(key, ':');
321
+ if (!colon) return false;
322
+ const char* quote = strchr(colon, '"');
323
+ if (!quote) return false;
324
+ const char* end = strchr(quote + 1, '"');
325
+ if (!end) return false;
326
+ size_t len = static_cast<size_t>(end - quote - 1);
327
+ if (len == 0 || len + 1 > out_len) return false;
328
+ memcpy(out, quote + 1, len);
329
+ out[len] = '\0';
330
+ return true;
331
+ }
332
+
333
+ } // namespace
334
+
335
+ const MIKOtaEnv* mik__ota_env_for(MIKRuntime* rt, int bytecode_version) {
336
+ g_state.rt = rt;
337
+ g_state.bytecode_version = bytecode_version;
338
+ const MIKPlatform* platform = MIK_GetPlatform();
339
+ const char* id = platform->get_device_id ? platform->get_device_id() : nullptr;
340
+ snprintf(g_state.device_id, sizeof(g_state.device_id), "%s", id ? id : "");
341
+
342
+ MIKOtaEnv& env = g_state.env;
343
+ env = {};
344
+ env.opaque = &g_state;
345
+
346
+ env.http_request = env_http_request;
347
+ env.http_cancel = env_http_cancel;
348
+
349
+ env.kv_get_blob = env_kv_get_blob;
350
+ env.kv_set_blob = env_kv_set_blob;
351
+ env.kv_get_str = env_kv_get_str;
352
+ env.kv_set_str = env_kv_set_str;
353
+ env.kv_get_i32 = env_kv_get_i32;
354
+ env.kv_set_i32 = env_kv_set_i32;
355
+ env.kv_remove = env_kv_remove;
356
+
357
+ mik__ota_fill_install_ops(&env);
358
+
359
+ env.identity = env_identity;
360
+ env.storage_free = env_storage_free;
361
+ env.get_device_name = env_get_device_name;
362
+ env.set_device_name = env_set_device_name;
363
+ env.restart = env_restart;
364
+ env.monotonic_ms = env_monotonic_ms;
365
+ env.random_fraction = env_random_fraction;
366
+ env.log = env_log;
367
+ env.read_app_version = env_read_app_version;
368
+ env.read_manifest = env_read_manifest;
369
+ return &env;
370
+ }
@@ -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
+ }
@@ -93,9 +93,14 @@ echo "== corrupt/truncated rejection =="
93
93
  head -c "$(( $(wc -c < "$BUILD/small.tgz") - 16 ))" "$BUILD/small.tgz" > "$BUILD/trunc.tgz"
94
94
  "$TEST" expect-fail "$BUILD/trunc.tgz" "$BUILD/out_trunc" >/dev/null && echo " truncated rejected ✓" \
95
95
  || { echo "FAIL: truncated build was accepted"; exit 1; }
96
- # Corrupt: flip a byte in the deflate body.
96
+ # Corrupt: flip a byte in the deflate body. XOR rather than overwrite: the
97
+ # fixture's compressed bytes shift with the host's gzip, and overwriting with a
98
+ # constant is a no-op whenever the byte already holds that value, which turns
99
+ # this case into "valid build rejected as accepted" on some runner images.
97
100
  cp "$BUILD/large.tgz" "$BUILD/corrupt.tgz"
98
- printf '\xff' | dd of="$BUILD/corrupt.tgz" bs=1 seek=64 count=1 conv=notrunc 2>/dev/null
101
+ orig=$(dd if="$BUILD/corrupt.tgz" bs=1 skip=64 count=1 2>/dev/null | od -An -tu1 | tr -d ' ')
102
+ printf "\\$(printf '%03o' $((orig ^ 0x5a)))" \
103
+ | dd of="$BUILD/corrupt.tgz" bs=1 seek=64 count=1 conv=notrunc 2>/dev/null
99
104
  "$TEST" expect-fail "$BUILD/corrupt.tgz" "$BUILD/out_corrupt" >/dev/null && echo " corrupt rejected ✓" \
100
105
  || { echo "FAIL: corrupt build was accepted"; exit 1; }
101
106
  # Not gzip at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/firmware",
3
- "version": "0.18.0",
3
+ "version": "0.18.2-next.20260826235606",
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.0",
51
- "@mikrojs/quickjs": "0.18.0"
50
+ "@mikrojs/native": "0.18.2-next.20260826235606+4369a4e",
51
+ "@mikrojs/quickjs": "0.18.2-next.20260826235606+4369a4e"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.0.0"
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -32,6 +32,22 @@ CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH=y
32
32
  CONFIG_ESP_WIFI_IRAM_OPT=n
33
33
  CONFIG_ESP_WIFI_RX_IRAM_OPT=n
34
34
 
35
+ # Trim WiFi driver buffers toward Espressif's low-RAM rank. The IDF defaults
36
+ # left too little heap for a TLS handshake next to a JS runtime on no-PSRAM
37
+ # chips. STATIC_RX is a real ~1.6 KB/buffer allocation made at wifi init; the
38
+ # DYNAMIC_* counts are ceilings, so they raise the guaranteed-free floor
39
+ # rather than idle heap. Paired with the lwIP window below, which the WiFi
40
+ # performance guide says must track the DYNAMIC_* counts.
41
+ CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6
42
+ CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=10
43
+ CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=10
44
+ # IDF default, and within the guide's min(2 x STATIC_RX, DYNAMIC_RX) bound.
45
+ # Lower values throttle downlink aggregation for a few pointers of RAM; to
46
+ # actually reclaim the reorder path, disable AMPDU RX instead.
47
+ CONFIG_ESP_WIFI_RX_BA_WIN=6
48
+ # Management short buffers. 6 is the Kconfig minimum and enough for a station.
49
+ CONFIG_ESP_WIFI_MGMT_SBUF_NUM=6
50
+
35
51
  # SPI master ISR doesn't need to run during flash writes
36
52
  CONFIG_SPI_MASTER_ISR_IN_IRAM=n
37
53
 
@@ -106,6 +122,31 @@ CONFIG_HAL_DEFAULT_ASSERTION_LEVEL=0
106
122
  # native module won't even compile. Cost is roughly 5–10 KB flash.
107
123
  CONFIG_LWIP_IPV6=y
108
124
 
125
+ # TIME_WAIT is 2xMSL. At the lwIP default (60s MSL) every closed TLS
126
+ # connection leaves heap residue for two minutes; on a client checking in
127
+ # on an interval, that residue fragments the heap until TLS handshakes
128
+ # fail (measured on esp32c6: largest free block ratchets down each round).
129
+ # 6s is safe for an outbound-only client: stale-segment reuse needs the
130
+ # same ephemeral port against the same server inside the window, and a
131
+ # stale segment that did land fails the TLS record MAC anyway. Note this is
132
+ # a global lwIP setting: in SoftAP mode the device is the side that lands in
133
+ # TIME_WAIT, where a short MSL is a weaker guarantee.
134
+ CONFIG_LWIP_TCP_MSL=6000
135
+ # FIN_WAIT_2 is the same lingering-socket story when the peer half-closes;
136
+ # the IDF lwIP guide names both timers for minimum-RAM builds.
137
+ CONFIG_LWIP_TCP_FIN_WAIT_TIMEOUT=6000
138
+
139
+ # Per-connection TCP buffering, and the paired half of the WiFi DYNAMIC_*
140
+ # counts above. 2880 is 2xMSS: it halves lwIP's per-connection peak, at the
141
+ # cost of roughly doubling OTA image download time on a high-latency path.
142
+ CONFIG_LWIP_TCP_SND_BUF_DEFAULT=2880
143
+ CONFIG_LWIP_TCP_WND_DEFAULT=2880
144
+ # Out-of-order segments are held as heap pbufs per pcb until the gap fills,
145
+ # which is exactly when a handshake wants a large contiguous block.
146
+ CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=2
147
+ # Bounds how many pbufs can queue up waiting on the tcpip task.
148
+ CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=12
149
+
109
150
  # --- MbedTLS: TLS 1.2 only, AES-GCM/CCM, ECDHE ---
110
151
  # 1.3's handshake working state plus its Kconfig-forced
111
152
  # KEEP_PEER_CERTIFICATE add several KB of peak heap per connection, so disable for now
@@ -125,6 +166,10 @@ CONFIG_MBEDTLS_FS_IO=n
125
166
  # Allocate TLS record buffers on demand (vs. a fixed 16 KB IN +
126
167
  # 4 KB OUT block alive per connection). Incompatible with DTLS.
127
168
  CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
169
+ # Every handshake TX step allocates OUT_CONTENT_LEN + record overhead, and it
170
+ # does so while the RX certificate buffer is still live. Caps the outbound
171
+ # record size, so large request bodies split across more records.
172
+ CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=2048
128
173
 
129
174
  # Drop the peer cert chain after handshake validates it (~4 KB per
130
175
  # connection). Note: TLS 1.3 force-selects this back on via Kconfig.
@@ -140,5 +185,18 @@ CONFIG_MBEDTLS_ECP_DP_BP512R1_ENABLED=n
140
185
 
141
186
  # Use smaller cert bundle (common CAs only)
142
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
189
+ # of heap. We need it: with n, esp_crt_bundle looks up only the top served
190
+ # cert's issuer, so any chain that serves a cross-signed root fails outright.
191
+ # That is ~2 in 5 real hosts, mostly Google Trust Services roots cross-signed
192
+ # by GlobalSign: openai, anthropic, cloudflare, npm, pypi, discord, googleapis.
193
+ # Device-confirmed against registry.npmjs.org (MBEDTLS_ERR_X509_FATAL_ERROR,
194
+ # -0x3000). Neither cross-signing root ships in the CMN or the full bundle, so
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.
143
201
  CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_CROSS_SIGNED_VERIFY=y
144
202