@mikrojs/firmware 0.6.1 → 0.7.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.
@@ -83,6 +83,7 @@ idf_component_register(
83
83
  "mik_deploy.cpp"
84
84
  "mik_http.cpp"
85
85
  "mik_i2c.cpp"
86
+ "mik_logfile.cpp"
86
87
  "mik_neopixel.cpp"
87
88
  "mik_pin.cpp"
88
89
  "mik_pwm.cpp"
@@ -41,6 +41,26 @@ int mik__console_read(void* buf, size_t len);
41
41
  /* Write to the active console. Returns bytes written. */
42
42
  int mik__console_write(const void* buf, size_t len);
43
43
 
44
+ /* Tap installed by mik_logfile so console output is mirrored into the
45
+ * on-device log file. Called from mik__console_write before the actual
46
+ * UART/USB-JTAG write, so output is captured even when no host is
47
+ * attached. Single slot; mik_logfile owns it. */
48
+ typedef void (*mik_console_tap_fn)(const void* buf, size_t len);
49
+ void mik__console_set_tap(mik_console_tap_fn fn);
50
+
51
+ /* File logging (mik_logfile.cpp).
52
+ * Initialized after MIK_LoadConfig once the filesystem is mounted.
53
+ * No-op when MIKConfig.log_file is empty. */
54
+ typedef struct MIKConfig MIKConfig;
55
+ void mik_logfile_init(const MIKConfig* config);
56
+ void mik_logfile_close(void);
57
+ void mik_logfile_flush(void);
58
+ /* Release/restore the underlying FILE* so the host can read the file
59
+ * without contention. Output between suspend/resume is dropped. No-op
60
+ * when file logging is disabled. */
61
+ void mik_logfile_suspend(void);
62
+ void mik_logfile_resume(void);
63
+
44
64
  /* Serial binary I/O (mik_serial_io.cpp) */
45
65
  void mik__serial_binary_begin_no_echo(void);
46
66
 
@@ -582,3 +582,53 @@ bool mik__handle_deploy_command(MIKReplTransport* transport, uint8_t cmd_type,
582
582
  return false;
583
583
  }
584
584
  }
585
+
586
+ /*
587
+ * Handle MIK_CMD_FS_GET: stream the contents of a file off the device.
588
+ * Payload: u16le path_len | path.
589
+ * Suspends the file logger so its buffered writes are flushed to disk and
590
+ * its FILE* released before we open the same path for reading, then
591
+ * resumes it once streaming is done.
592
+ */
593
+ bool mik__handle_fs_get(MIKReplTransport* transport, uint32_t payload_len) {
594
+ if (payload_len < 2) {
595
+ mik__proto_drain(transport, payload_len);
596
+ mik__proto_send_err(transport, "fs get: short header");
597
+ return true;
598
+ }
599
+ uint8_t nl[2];
600
+ if (!mik__proto_read_exact(transport, nl, 2)) return false;
601
+ uint16_t path_len = nl[0] | (nl[1] << 8);
602
+ if (path_len == 0 || path_len >= 256 || (uint32_t)path_len + 2 > payload_len) {
603
+ mik__proto_drain(transport, payload_len - 2);
604
+ mik__proto_send_err(transport, "fs get: bad path length");
605
+ return true;
606
+ }
607
+ char path[256];
608
+ if (!mik__proto_read_exact(transport, path, path_len)) return false;
609
+ path[path_len] = '\0';
610
+ uint32_t consumed = 2 + (uint32_t)path_len;
611
+ if (payload_len > consumed) mik__proto_drain(transport, payload_len - consumed);
612
+
613
+ mik_logfile_suspend();
614
+
615
+ FILE* f = fopen(path, "r");
616
+ if (!f) {
617
+ char msg[160];
618
+ snprintf(msg, sizeof(msg), "fs get: open failed: %s", strerror(errno));
619
+ mik__proto_send_err(transport, msg);
620
+ mik_logfile_resume();
621
+ return true;
622
+ }
623
+
624
+ uint8_t buf[512];
625
+ for (;;) {
626
+ size_t n = fread(buf, 1, sizeof(buf), f);
627
+ if (n == 0) break;
628
+ mik__proto_send(transport, MIK_MSG_FS_CHUNK, buf, n);
629
+ }
630
+ fclose(f);
631
+ mik_logfile_resume();
632
+ mik__proto_send_ok(transport);
633
+ return true;
634
+ }
@@ -668,15 +668,48 @@ static JSValue mik__http_next_message(JSContext* ctx, JSValue this_val, int argc
668
668
  return promise;
669
669
  }
670
670
 
671
- /* pendingCount() number of in-flight requests whose terminal message has
672
- * not yet been consumed. Useful for tests that want to verify cancel+drain
673
- * freed a slot without relying on heap-headroom for a follow-up request. */
671
+ /* Forward decl: defined further down, called here so pendingCount() drains
672
+ * result_queue before reading the counter. */
673
+ void mik__http_consume(JSContext* ctx);
674
+
675
+ /* pendingCount() — number of in-flight requests with work still visible to
676
+ * JS (headers/body not yet delivered, not cancelled). Useful for tests that
677
+ * want to verify cancel+drain freed a slot without relying on heap-headroom
678
+ * for a follow-up request.
679
+ *
680
+ * Semantic shift vs. a "raw slot count": this returns the JS-visible pending
681
+ * count, not the native slot count. Cancelled-but-not-yet-drained entries
682
+ * still occupy a native slot (until the BG task wakes from a blocking call
683
+ * and posts its terminal message) but are excluded from this count. If you
684
+ * need to know whether a follow-up `request()` will hit TooManyPending, this
685
+ * is not the right number — the slot may still be held. See
686
+ * MIK_HTTP_MAX_PENDING and the js_cancelled exclusion below.
687
+ *
688
+ * Drains result_queue first via the consume loop. Without this, a request
689
+ * whose native task has already posted its terminal message but whose
690
+ * message hasn't been processed by the next event-loop tick reads as
691
+ * "still pending" — even though it'd drop microseconds later. Surfaced
692
+ * as an intermittent "in-flight HTTP request at teardown" warning when
693
+ * the test harness reads pendingCount() synchronously right after the
694
+ * last awaited test step. */
674
695
  static JSValue mik__http_pending_count(JSContext* ctx, JSValue this_val, int argc,
675
696
  JSValue* argv) {
676
697
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
677
698
  CHECK_NOT_NULL(mik_rt);
678
699
  if (!mik__http_st(mik_rt)) return JS_NewUint32(ctx, 0);
679
- return JS_NewUint32(ctx, mik__http_st(mik_rt)->pending_count);
700
+ mik__http_consume(ctx);
701
+ /* Exclude explicitly-cancelled entries. JS has signalled intent to
702
+ * release; the entry stays alive only until the native task wakes
703
+ * from a blocking call (TLS handshake, DNS) and posts its terminal
704
+ * message. That's not a leak — counting it as one fires false
705
+ * "in-flight HTTP at teardown" warnings on tests that abort or
706
+ * time out a request and exit before the native task has noticed. */
707
+ MIKHttpState* state = mik__http_st(mik_rt);
708
+ uint32_t live = 0;
709
+ for (size_t i = 0; i < state->pending_count; i++) {
710
+ if (!state->pending[i].js_cancelled) live++;
711
+ }
712
+ return JS_NewUint32(ctx, live);
680
713
  }
681
714
 
682
715
  /* cancel(id) — signal a pending request to abort */
@@ -0,0 +1,269 @@
1
+ #include "mikrojs/mikrojs.h"
2
+ #include "mikrojs/platform.h"
3
+ #include "mikrojs/private.h"
4
+ #include "mikrojs_esp32.h"
5
+
6
+ #include <cstdarg>
7
+ #include <cstdio>
8
+ #include <cstring>
9
+ #include <ctime>
10
+ #include <sys/stat.h>
11
+ #include <sys/time.h>
12
+ #include <unistd.h>
13
+
14
+ #include "esp_log.h"
15
+ #include "esp_timer.h"
16
+ #include "freertos/FreeRTOS.h"
17
+ #include "freertos/semphr.h"
18
+
19
+ static const char* TAG = "mik_logfile";
20
+
21
+ /* Static buffers — total ~1.5 KiB plus a FreeRTOS mutex. */
22
+ static char s_line_buf[512]; /* current line being assembled */
23
+ static char s_stdio_buf[1024]; /* setvbuf target for the FILE* */
24
+ static char s_path_main[96]; /* "<dir>/log.txt" */
25
+ static char s_path_rot[100]; /* "<dir>/log.txt.1" */
26
+ static SemaphoreHandle_t s_mtx = nullptr;
27
+ static FILE* s_file = nullptr;
28
+ static size_t s_file_size = 0;
29
+ static size_t s_line_pos = 0;
30
+ static bool s_line_has_ts = false;
31
+ static uint32_t s_max_size = 0;
32
+ static MIKLogFlush s_flush_policy = MIK_LOG_FLUSH_ERROR;
33
+ static vprintf_like_t s_prev_vprintf = nullptr;
34
+
35
+ /* Time threshold for treating the RTC as wall-clock-set (Jan 1 2020). */
36
+ static const time_t WALL_CLOCK_THRESHOLD = 1577836800;
37
+
38
+ static int format_timestamp(char* buf, size_t buf_size) {
39
+ struct timeval tv;
40
+ gettimeofday(&tv, nullptr);
41
+ if (tv.tv_sec > WALL_CLOCK_THRESHOLD) {
42
+ struct tm tm;
43
+ gmtime_r(&tv.tv_sec, &tm);
44
+ return snprintf(buf, buf_size, "%04d-%02d-%02dT%02d:%02d:%02d.%03ldZ ",
45
+ tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min,
46
+ tm.tm_sec, (long)(tv.tv_usec / 1000));
47
+ }
48
+ int64_t us = esp_timer_get_time();
49
+ int64_t s = us / 1000000;
50
+ int64_t ms = (us / 1000) % 1000;
51
+ return snprintf(buf, buf_size, "[+%lld.%03llds] ", (long long)s, (long long)ms);
52
+ }
53
+
54
+ /* Recognize lines that should trigger an immediate fflush under flush='error'.
55
+ * Patterns: ESP_LOG ("E (12345)" / "W (12345)") and mikrojs platform.log
56
+ * ("[ERROR]" / "[WARN]"). False negatives just delay the flush — they don't
57
+ * lose data — so a conservative match is fine. */
58
+ static bool line_is_error_level(const char* line, size_t len) {
59
+ if (len >= 2 && (line[0] == 'E' || line[0] == 'W') && line[1] == ' ') return true;
60
+ if (len >= 7 && memcmp(line, "[ERROR]", 7) == 0) return true;
61
+ if (len >= 6 && memcmp(line, "[WARN]", 6) == 0) return true;
62
+ return false;
63
+ }
64
+
65
+ /* Caller holds s_mtx. */
66
+ static void rotate_if_needed() {
67
+ if (s_file_size < s_max_size) return;
68
+ fclose(s_file);
69
+ s_file = nullptr;
70
+ unlink(s_path_rot);
71
+ rename(s_path_main, s_path_rot);
72
+ s_file = fopen(s_path_main, "a");
73
+ if (s_file) {
74
+ setvbuf(s_file, s_stdio_buf, _IOFBF, sizeof(s_stdio_buf));
75
+ s_file_size = 0;
76
+ }
77
+ }
78
+
79
+ /* Caller holds s_mtx. */
80
+ static void emit_line() {
81
+ if (s_line_pos == 0) return;
82
+ if (!s_file) {
83
+ s_line_pos = 0;
84
+ s_line_has_ts = false;
85
+ return;
86
+ }
87
+ size_t n = fwrite(s_line_buf, 1, s_line_pos, s_file);
88
+ s_file_size += n;
89
+ bool is_err = line_is_error_level(s_line_buf, s_line_pos);
90
+ if (s_flush_policy == MIK_LOG_FLUSH_LINE ||
91
+ (s_flush_policy == MIK_LOG_FLUSH_ERROR && is_err)) {
92
+ fflush(s_file);
93
+ }
94
+ rotate_if_needed();
95
+ s_line_pos = 0;
96
+ s_line_has_ts = false;
97
+ }
98
+
99
+ /* Caller holds s_mtx. */
100
+ static void append_byte(uint8_t c) {
101
+ if (!s_line_has_ts) {
102
+ int n = format_timestamp(s_line_buf, sizeof(s_line_buf) - 1);
103
+ if (n < 0 || (size_t)n >= sizeof(s_line_buf) - 1) n = 0;
104
+ s_line_pos = (size_t)n;
105
+ s_line_has_ts = true;
106
+ }
107
+ if (s_line_pos < sizeof(s_line_buf) - 1) {
108
+ s_line_buf[s_line_pos++] = (char)c;
109
+ }
110
+ if (c == '\n' || s_line_pos >= sizeof(s_line_buf) - 1) {
111
+ if (s_line_pos == 0 || s_line_buf[s_line_pos - 1] != '\n') {
112
+ s_line_buf[s_line_pos++] = '\n';
113
+ }
114
+ emit_line();
115
+ }
116
+ }
117
+
118
+ static void console_tap(const void* buf, size_t len) {
119
+ if (!s_mtx) return;
120
+ if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(50)) != pdTRUE) return;
121
+ const uint8_t* p = (const uint8_t*)buf;
122
+ for (size_t i = 0; i < len; i++) append_byte(p[i]);
123
+ xSemaphoreGive(s_mtx);
124
+ }
125
+
126
+ /* Fired by mik__repl_proto_send_output before TLV framing. Gives us the
127
+ * un-wrapped body bytes plus the message type, so console.error etc.
128
+ * forces an immediate flush without relying on prefix pattern matching. */
129
+ static void log_emit_tap(uint8_t msg_type, const void* data, size_t len) {
130
+ if (!s_mtx) return;
131
+ if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(50)) != pdTRUE) return;
132
+ const uint8_t* p = (const uint8_t*)data;
133
+ for (size_t i = 0; i < len; i++) append_byte(p[i]);
134
+ /* Ensure the line is closed even if the body lacks a trailing newline
135
+ * (mik__repl_proto_send_output is called once per logical message). */
136
+ if (s_line_pos > 0 && s_line_buf[s_line_pos - 1] != '\n') {
137
+ append_byte((uint8_t)'\n');
138
+ }
139
+ if (s_file && (msg_type == MIK_MSG_ERROR || msg_type == MIK_MSG_WARN ||
140
+ msg_type == MIK_MSG_EVAL_ERROR)) {
141
+ fflush(s_file);
142
+ }
143
+ xSemaphoreGive(s_mtx);
144
+ }
145
+
146
+ static int log_vprintf_tap(const char* fmt, va_list args) {
147
+ /* Forward to the previously-installed vprintf so terminal output is
148
+ * unchanged, then format a copy into the line buffer for the file. */
149
+ va_list copy;
150
+ va_copy(copy, args);
151
+ int rv = s_prev_vprintf ? s_prev_vprintf(fmt, args) : vprintf(fmt, args);
152
+
153
+ if (s_mtx) {
154
+ char line[256];
155
+ int n = vsnprintf(line, sizeof(line), fmt, copy);
156
+ if (n > 0) {
157
+ if ((size_t)n >= sizeof(line)) n = (int)sizeof(line) - 1;
158
+ if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(50)) == pdTRUE) {
159
+ for (int i = 0; i < n; i++) append_byte((uint8_t)line[i]);
160
+ xSemaphoreGive(s_mtx);
161
+ }
162
+ }
163
+ }
164
+ va_end(copy);
165
+ return rv;
166
+ }
167
+
168
+ void mik_logfile_init(const MIKConfig* config) {
169
+ const MIKPlatform* platform = MIK_GetPlatform();
170
+ if (!config || config->log_dir[0] == '\0') return;
171
+ if (s_file) return;
172
+
173
+ /* Ensure the directory exists. mkdir errors when it already exists,
174
+ * which is fine. */
175
+ mkdir(config->log_dir, 0775);
176
+
177
+ if ((size_t)snprintf(s_path_main, sizeof(s_path_main), "%s/log.txt", config->log_dir) >=
178
+ sizeof(s_path_main)) {
179
+ platform->log(MIK_LOG_WARN, TAG, "log dir path too long");
180
+ return;
181
+ }
182
+ snprintf(s_path_rot, sizeof(s_path_rot), "%s.1", s_path_main);
183
+
184
+ s_mtx = xSemaphoreCreateMutex();
185
+ if (!s_mtx) return;
186
+
187
+ s_file = fopen(s_path_main, "a");
188
+ if (!s_file) {
189
+ platform->log(MIK_LOG_WARN, TAG, "Could not open log file");
190
+ vSemaphoreDelete(s_mtx);
191
+ s_mtx = nullptr;
192
+ return;
193
+ }
194
+ setvbuf(s_file, s_stdio_buf, _IOFBF, sizeof(s_stdio_buf));
195
+ fseek(s_file, 0, SEEK_END);
196
+ long pos = ftell(s_file);
197
+ s_file_size = pos > 0 ? (size_t)pos : 0;
198
+ s_max_size = config->log_max_size;
199
+ s_flush_policy = config->log_flush;
200
+
201
+ mik__console_set_tap(console_tap);
202
+ mik__set_log_emit_tap(log_emit_tap);
203
+ s_prev_vprintf = esp_log_set_vprintf(log_vprintf_tap);
204
+
205
+ platform->log(MIK_LOG_INFO, TAG, "File log enabled (size=%u, max=%u)",
206
+ (unsigned)s_file_size, (unsigned)s_max_size);
207
+ }
208
+
209
+ void mik_logfile_suspend(void) {
210
+ if (!s_mtx) return;
211
+ if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(100)) != pdTRUE) return;
212
+ if (s_file) {
213
+ fflush(s_file);
214
+ fclose(s_file);
215
+ s_file = nullptr;
216
+ }
217
+ xSemaphoreGive(s_mtx);
218
+ }
219
+
220
+ void mik_logfile_resume(void) {
221
+ if (!s_mtx) return;
222
+ if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(100)) != pdTRUE) return;
223
+ if (!s_file) {
224
+ s_file = fopen(s_path_main, "a");
225
+ if (s_file) {
226
+ setvbuf(s_file, s_stdio_buf, _IOFBF, sizeof(s_stdio_buf));
227
+ fseek(s_file, 0, SEEK_END);
228
+ long pos = ftell(s_file);
229
+ s_file_size = pos > 0 ? (size_t)pos : 0;
230
+ }
231
+ }
232
+ xSemaphoreGive(s_mtx);
233
+ }
234
+
235
+ void mik_logfile_flush(void) {
236
+ if (!s_mtx || !s_file) return;
237
+ if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(50)) != pdTRUE) return;
238
+ fflush(s_file);
239
+ xSemaphoreGive(s_mtx);
240
+ }
241
+
242
+ void mik_logfile_close(void) {
243
+ if (!s_mtx) return;
244
+ if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(100)) != pdTRUE) return;
245
+ mik__console_set_tap(nullptr);
246
+ mik__set_log_emit_tap(nullptr);
247
+ if (s_prev_vprintf) {
248
+ esp_log_set_vprintf(s_prev_vprintf);
249
+ s_prev_vprintf = nullptr;
250
+ }
251
+ if (s_line_pos > 0) {
252
+ if (s_line_buf[s_line_pos - 1] != '\n' && s_line_pos < sizeof(s_line_buf)) {
253
+ s_line_buf[s_line_pos++] = '\n';
254
+ }
255
+ if (s_file) fwrite(s_line_buf, 1, s_line_pos, s_file);
256
+ s_line_pos = 0;
257
+ }
258
+ if (s_file) {
259
+ fflush(s_file);
260
+ fclose(s_file);
261
+ s_file = nullptr;
262
+ }
263
+ s_file_size = 0;
264
+ s_line_has_ts = false;
265
+ SemaphoreHandle_t mtx = s_mtx;
266
+ s_mtx = nullptr;
267
+ xSemaphoreGive(mtx);
268
+ vSemaphoreDelete(mtx);
269
+ }
@@ -127,6 +127,7 @@ bool mik__handle_deploy_command(MIKReplTransport* transport, uint8_t cmd_type,
127
127
  uint32_t payload_len);
128
128
  bool mik__handle_config_command(MIKReplTransport* transport, uint8_t cmd_type,
129
129
  uint32_t payload_len);
130
+ bool mik__handle_fs_get(MIKReplTransport* transport, uint32_t payload_len);
130
131
  void mik__deploy_session_reset(void);
131
132
 
132
133
  static void platform_session_end(void* ctx) {
@@ -163,6 +164,11 @@ static bool platform_command_handler(MIKReplTransport* transport, uint8_t cmd_ty
163
164
  return mik__handle_deploy_command(transport, cmd_type, payload_len);
164
165
  }
165
166
 
167
+ /* File pull (0x2B) */
168
+ if (cmd_type == MIK_CMD_FS_GET) {
169
+ return mik__handle_fs_get(transport, payload_len);
170
+ }
171
+
166
172
  /* Config commands (0x40-0x42) */
167
173
  if (cmd_type >= MIK_CMD_CONFIG_LIST && cmd_type <= MIK_CMD_CONFIG_DELETE) {
168
174
  return mik__handle_config_command(transport, cmd_type, payload_len);
@@ -277,6 +283,9 @@ void MIK_Main(void) {
277
283
  MIKConfig app_config;
278
284
  MIK_LoadConfig("/appfs", &app_config);
279
285
 
286
+ /* Start file logging if configured (no-op when log_file is empty). */
287
+ mik_logfile_init(&app_config);
288
+
280
289
  /* Create JS runtime — reserve heap for WiFi, TLS, HTTP, LittleFS, and other
281
290
  * ESP-IDF subsystems that allocate after the runtime is created.
282
291
  * WiFi + lwIP ≈ 50-65 KB, TLS ≈ 40 KB, HTTP task ≈ 8 KB, misc ≈ 15 KB. */
@@ -162,7 +162,21 @@ int mik__console_read(void* buf, size_t len) {
162
162
  #endif
163
163
  }
164
164
 
165
+ static mik_console_tap_fn s_console_tap = nullptr;
166
+
167
+ void mik__console_set_tap(mik_console_tap_fn fn) {
168
+ s_console_tap = fn;
169
+ }
170
+
165
171
  int mik__console_write(const void* buf, size_t len) {
172
+ /* Mirror to file-log tap (if installed) before the host write. This
173
+ * captures output even when USB-JTAG has no host attached. Skip the
174
+ * tap while a TLV protocol frame is being written — the file logger
175
+ * has a higher-level tap on the un-framed body via the log-emit
176
+ * hook, so capturing the wire bytes here would just embed the
177
+ * [type][len] header in the log file. */
178
+ mik_console_tap_fn tap = s_console_tap;
179
+ if (tap && !mik__proto_send_in_progress) tap(buf, len);
166
180
  #if SOC_USB_SERIAL_JTAG_SUPPORTED
167
181
  if (s_console == CONSOLE_USB_SERIAL_JTAG) {
168
182
  /* No host attached: the ring buffer would fill and block writes
@@ -12,6 +12,11 @@
12
12
  static JSClassID mik_uart_class_id;
13
13
  static int mik__uart_slot = -1;
14
14
 
15
+ /* Forward decl: defined alongside the iterator class further down. The Uart
16
+ * state holds a non-owning backpointer so end() can mark the active iterator
17
+ * as exhausted without having to reach through JS. */
18
+ struct MIKUartIterState;
19
+
15
20
  /* ── State ────────────────────────────────────────────────────────── */
16
21
 
17
22
  struct MIKUartState {
@@ -22,6 +27,11 @@ struct MIKUartState {
22
27
  bool begun;
23
28
  bool reading; // active read() iterator exists
24
29
  MIKPromise read_promise; // pending next() promise (when waiting for data)
30
+ /* Non-owning ref to the active iterator (when reading == true). The iter
31
+ * holds a strong ref to this Uart via uart_jsval, so this back-edge is
32
+ * always cleared (iter_return / finalizer) before the Uart can outlive
33
+ * the iterator. */
34
+ MIKUartIterState* iter;
25
35
  };
26
36
 
27
37
  /* Per-runtime tracking of all Uart instances for the loop consumer */
@@ -98,6 +108,7 @@ static JSValue js_uart_constructor(JSContext* ctx, JSValue new_target, int argc,
98
108
  s->baud_rate = 115200;
99
109
  s->begun = false;
100
110
  s->reading = false;
111
+ s->iter = nullptr;
101
112
  s->read_promise.p = JS_UNDEFINED;
102
113
  s->read_promise.rfuncs[0] = JS_UNDEFINED;
103
114
  s->read_promise.rfuncs[1] = JS_UNDEFINED;
@@ -193,19 +204,29 @@ static JSValue js_uart_begin(JSContext* ctx, JSValue this_val, int argc, JSValue
193
204
  return mik__result_ok_void(ctx);
194
205
  }
195
206
 
207
+ /* Forward decls — bodies live with the iterator class. */
208
+ static JSValue mik__uart_iter_yield(JSContext* ctx, JSValue inner_result);
209
+ static void mik__uart_iter_mark_ended(MIKUartIterState* it);
210
+
196
211
  static JSValue js_uart_end(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
197
212
  auto* s = mik__uart_get(ctx, this_val);
198
213
  if (!s) return JS_EXCEPTION;
199
214
  if (!s->begun) return mik__result_ok_void(ctx); // idempotent
200
215
 
201
- /* Cancel any pending read. MIK_FreePromise frees the JSValues but
202
- * doesn't reset the slot, so MIK_ClearPromise is required to keep
203
- * MIK_IsPromisePending honest if the Uart is begun + read again. */
216
+ /* If a read() iterator is active, deliver a terminal err item so any
217
+ * awaiting next() unblocks with err(NotStarted) rather than hanging.
218
+ * Also flip the iterator's `ended` flag so its next call resolves with
219
+ * {done:true} — without this, the awaiter consumes the err yield, then
220
+ * the next call hits the `!s->begun` branch in iter_next and emits a
221
+ * second err yield. The active iterator backpointer makes that flip
222
+ * possible without reaching through JS. */
204
223
  if (s->reading) {
205
224
  if (MIK_IsPromisePending(ctx, &s->read_promise)) {
206
- MIK_FreePromise(ctx, &s->read_promise);
225
+ JSValue err_yield = mik__uart_iter_yield(ctx, mik__result_err_tag(ctx, "NotStarted"));
226
+ MIK_ResolvePromise(ctx, &s->read_promise, 1, &err_yield);
207
227
  MIK_ClearPromise(ctx, &s->read_promise);
208
228
  }
229
+ if (s->iter) mik__uart_iter_mark_ended(s->iter);
209
230
  s->reading = false;
210
231
  }
211
232
 
@@ -260,6 +281,7 @@ struct MIKUartIterState {
260
281
  * iterator could outlive its parent Uart, leading to a UAF here. */
261
282
  MIKUartState* uart;
262
283
  JSValue uart_jsval;
284
+ bool ended; // sticky: once true, next() returns {done:true} immediately
263
285
  };
264
286
 
265
287
  static void mik__uart_iter_finalizer(JSRuntime* rt, JSValue val) {
@@ -269,11 +291,16 @@ static void mik__uart_iter_finalizer(JSRuntime* rt, JSValue val) {
269
291
  * safe here — except after iterator.return() nulled it out. */
270
292
  if (it->uart) {
271
293
  it->uart->reading = false;
294
+ if (it->uart->iter == it) it->uart->iter = nullptr;
272
295
  }
273
296
  JS_FreeValueRT(rt, it->uart_jsval);
274
297
  free(it);
275
298
  }
276
299
 
300
+ static void mik__uart_iter_mark_ended(MIKUartIterState* it) {
301
+ if (it) it->ended = true;
302
+ }
303
+
277
304
  static void mik__uart_iter_gc_mark(JSRuntime* rt, JSValue val, JS_MarkFunc* mark_func) {
278
305
  auto* it = static_cast<MIKUartIterState*>(JS_GetOpaque(val, mik_uart_iter_class_id));
279
306
  if (!it) return;
@@ -286,7 +313,24 @@ static JSClassDef mik_uart_iter_class = {
286
313
  .gc_mark = mik__uart_iter_gc_mark,
287
314
  };
288
315
 
289
- /* Try to read available data and return {done: false, value: Uint8Array} synchronously */
316
+ /* Build {done:false, value: Result<Uint8Array, UartError>} wrapper. Takes
317
+ * ownership of the inner Result value. */
318
+ static JSValue mik__uart_iter_yield(JSContext* ctx, JSValue inner_result) {
319
+ JSValue out = JS_NewObject(ctx);
320
+ JS_DefinePropertyValueStr(ctx, out, "done", JS_FALSE, JS_PROP_C_W_E);
321
+ JS_DefinePropertyValueStr(ctx, out, "value", inner_result, JS_PROP_C_W_E);
322
+ return out;
323
+ }
324
+
325
+ static JSValue mik__uart_iter_done(JSContext* ctx) {
326
+ JSValue out = JS_NewObject(ctx);
327
+ JS_DefinePropertyValueStr(ctx, out, "done", JS_TRUE, JS_PROP_C_W_E);
328
+ JS_DefinePropertyValueStr(ctx, out, "value", JS_UNDEFINED, JS_PROP_C_W_E);
329
+ return out;
330
+ }
331
+
332
+ /* Try to read available data and return {done:false, value: ok(Uint8Array)}
333
+ * synchronously, or JS_UNDEFINED if no data is buffered yet. */
290
334
  static JSValue mik__uart_try_read(JSContext* ctx, MIKUartState* s) {
291
335
  size_t buffered = 0;
292
336
  uart_get_buffered_data_len(s->port, &buffered);
@@ -302,18 +346,27 @@ static JSValue mik__uart_try_read(JSContext* ctx, MIKUartState* s) {
302
346
  }
303
347
 
304
348
  JSValue arr = MIK_NewUint8Array(ctx, buf, read);
305
- JSValue result = JS_NewObject(ctx);
306
- JS_DefinePropertyValueStr(ctx, result, "done", JS_FALSE, JS_PROP_C_W_E);
307
- JS_DefinePropertyValueStr(ctx, result, "value", arr, JS_PROP_C_W_E);
308
- return result;
349
+ return mik__uart_iter_yield(ctx, mik__result_ok(ctx, arr));
309
350
  }
310
351
 
311
352
  static JSValue js_uart_iter_next(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
312
353
  auto* it = static_cast<MIKUartIterState*>(JS_GetOpaque2(ctx, this_val, mik_uart_iter_class_id));
313
354
  if (!it || !it->uart) return JS_ThrowInternalError(ctx, "invalid uart iterator");
355
+
356
+ if (it->ended) {
357
+ JSValue done_result = mik__uart_iter_done(ctx);
358
+ return MIK_NewResolvedPromise(ctx, 1, &done_result);
359
+ }
360
+
314
361
  MIKUartState* s = it->uart;
315
362
 
316
- if (!s->begun) return JS_Throw(ctx, JS_NewString(ctx, "UART not started"));
363
+ /* If end() has invalidated the underlying port, surface NotStarted as
364
+ * a single err item and mark the iterator done. */
365
+ if (!s->begun) {
366
+ it->ended = true;
367
+ JSValue err_yield = mik__uart_iter_yield(ctx, mik__result_err_tag(ctx, "NotStarted"));
368
+ return MIK_NewResolvedPromise(ctx, 1, &err_yield);
369
+ }
317
370
 
318
371
  /* Try synchronous read first */
319
372
  JSValue sync_result = mik__uart_try_read(ctx, s);
@@ -343,6 +396,7 @@ static JSValue js_uart_iter_return(JSContext* ctx, JSValue this_val, int argc, J
343
396
  }
344
397
 
345
398
  it->uart->reading = false;
399
+ if (it->uart->iter == it) it->uart->iter = nullptr;
346
400
  it->uart = nullptr;
347
401
 
348
402
  done:
@@ -384,6 +438,7 @@ static JSValue js_uart_read(JSContext* ctx, JSValue this_val, int argc, JSValue*
384
438
  return JS_EXCEPTION;
385
439
  }
386
440
  JS_SetOpaque(iter_obj, it);
441
+ s->iter = it;
387
442
 
388
443
  return mik__result_ok(ctx, iter_obj);
389
444
  }
@@ -468,9 +523,7 @@ void mik__uart_consume(JSContext* ctx) {
468
523
  }
469
524
 
470
525
  JSValue arr = MIK_NewUint8Array(ctx, buf, read_bytes);
471
- JSValue result = JS_NewObject(ctx);
472
- JS_DefinePropertyValueStr(ctx, result, "done", JS_FALSE, JS_PROP_C_W_E);
473
- JS_DefinePropertyValueStr(ctx, result, "value", arr, JS_PROP_C_W_E);
526
+ JSValue result = mik__uart_iter_yield(ctx, mik__result_ok(ctx, arr));
474
527
  MIK_ResolvePromise(ctx, &s->read_promise, 1, &result);
475
528
  MIK_ClearPromise(ctx, &s->read_promise);
476
529
  }
@@ -182,12 +182,22 @@ static void esp32_log(int level, const char* tag, const char* fmt, ...) {
182
182
  esp_log_level_t tag_level = esp_log_level_get(tag);
183
183
  if (tag_level == ESP_LOG_NONE || esp_level > tag_level) return;
184
184
 
185
- printf("[%s] %s: ", mik_log_level_name(level), tag);
185
+ /* Route through mik__console_write rather than printf so the file-log
186
+ * tap captures these lines along with JS console output. */
187
+ char buf[256];
188
+ int n = snprintf(buf, sizeof(buf), "[%s] %s: ", mik_log_level_name(level), tag);
189
+ if (n < 0) return;
190
+ if ((size_t)n >= sizeof(buf)) n = (int)sizeof(buf) - 1;
186
191
  va_list args;
187
192
  va_start(args, fmt);
188
- vprintf(fmt, args);
193
+ int m = vsnprintf(buf + n, sizeof(buf) - (size_t)n, fmt, args);
189
194
  va_end(args);
190
- putchar('\n');
195
+ if (m < 0) m = 0;
196
+ size_t total = (size_t)n + (size_t)m;
197
+ if (total >= sizeof(buf)) total = sizeof(buf) - 1;
198
+ /* Replace trailing NUL with newline; if exactly full, overwrite last byte. */
199
+ buf[total] = '\n';
200
+ mik__console_write(buf, total + 1);
191
201
  }
192
202
 
193
203
  static const MIKPlatform esp32_platform = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/firmware",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Mikro.js ESP32 firmware: ESP-IDF component, build tools, and project template",
5
5
  "keywords": [
6
6
  "esp-idf",
@@ -51,8 +51,8 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "esbuild": "^0.28.0",
54
- "@mikrojs/quickjs": "0.6.1",
55
- "@mikrojs/native": "0.6.1"
54
+ "@mikrojs/native": "0.7.0",
55
+ "@mikrojs/quickjs": "0.7.0"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=24.0.0"
Binary file
Binary file
Binary file
Binary file
Binary file