@mikrojs/native 0.20.1 → 0.20.2-next.20260907205905

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.
@@ -93,7 +93,9 @@ void MIK_FreeRuntime(MIKRuntime* mik_rt);
93
93
  * -EINVAL runtime or entry is null/empty
94
94
  * -ENOENT entry (and its .bjs variant) not on disk
95
95
  * -EFAULT module evaluation threw
96
- * Emits no diagnostic output callers decide whether and how to log. */
96
+ * A synchronous throw (a missing import, a syntax error) is reported like an
97
+ * uncaught exception found by MIK_Loop: error handler, dump, MIK_Stop. A
98
+ * rejected eval promise is reported by the next loop's rejection flush. */
97
99
  int MIK_RunEntry(MIKRuntime* mik_rt, const char* entry);
98
100
 
99
101
  /* MIK_RunEntry variant that, on -EFAULT, copies the thrown exception's
@@ -146,13 +146,15 @@ struct MIKOtaEnv {
146
146
  MIKOtaKvStatus (*kv_get_blob)(void* opaque, const char* key, uint8_t* out_buf,
147
147
  size_t* inout_len);
148
148
  bool (*kv_set_blob)(void* opaque, const char* key, const uint8_t* data, size_t len);
149
- /* NOTE: the string and integer getters are still two-valued. Their callers
150
- * (the retry-budget store) read a failure as absence, which hands the budget
151
- * back a boot early. Same class of bug as the blob case above; worth the same
152
- * treatment when the policy's error handling is revisited. */
153
- bool (*kv_get_str)(void* opaque, const char* key, char* out_buf, size_t max_len);
149
+ /* Same contract as kv_get_blob: absent and failed are different answers.
150
+ * The retry budget lives behind these two, and every fallback a caller
151
+ * would pick for "absent" grants the device more than it has earned: an
152
+ * unspent budget, a url that looks new, no bad build on record. Stored
153
+ * bytes that do not decode report MIK_OTA_KV_ERROR too: garbage is not
154
+ * absence either. */
155
+ MIKOtaKvStatus (*kv_get_str)(void* opaque, const char* key, char* out_buf, size_t max_len);
154
156
  bool (*kv_set_str)(void* opaque, const char* key, const char* val);
155
- bool (*kv_get_i32)(void* opaque, const char* key, int32_t* out_val);
157
+ MIKOtaKvStatus (*kv_get_i32)(void* opaque, const char* key, int32_t* out_val);
156
158
  bool (*kv_set_i32)(void* opaque, const char* key, int32_t val);
157
159
  bool (*kv_remove)(void* opaque, const char* key);
158
160
 
@@ -92,8 +92,24 @@ public:
92
92
  void SetDecline(const MIKOtaDeclineRecord& record);
93
93
  void ClearDecline();
94
94
 
95
+ /* True once any read on this store has failed (as opposed to finding the
96
+ * key absent). Each getter has to answer with something, and the honest
97
+ * fallback for an unreadable key is the same everywhere: nothing stored.
98
+ * That reads as an unspent budget, a url that never matched, no abandoned
99
+ * build, every one of them more permission than the device has earned.
100
+ * Callers that are about to widen what the device may do consult this
101
+ * instead, and do nothing on an unreadable store; the next boot re-reads
102
+ * the real values. Latches for the life of one policy operation, which is
103
+ * how long a store lives. */
104
+ bool ReadFailed() const { return read_failed_; }
105
+
95
106
  private:
107
+ /* Runs every read through one place so the latch cannot be forgotten. */
108
+ MIKOtaKvStatus ReadStr(const char* key, char* out, size_t max_len) const;
109
+ MIKOtaKvStatus ReadI32(const char* key, int32_t* out) const;
110
+
96
111
  const MIKOtaEnv* env_;
112
+ mutable bool read_failed_ = false;
97
113
  };
98
114
 
99
115
  /* Validate untrusted offer fields into a well-formed MIKOtaOffer */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.20.1",
3
+ "version": "0.20.2-next.20260907205905",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -84,18 +84,18 @@
84
84
  "./runtime/wifi/types": "./runtime/wifi/types.ts"
85
85
  },
86
86
  "dependencies": {
87
+ "@mikrojs/quickjs": "0.20.2-next.20260907205905+8b6f502",
87
88
  "cmake-js": "^8.0.0",
88
- "node-addon-api": "^8.7.0",
89
- "node-gyp-build": "^4.8.4",
90
- "@mikrojs/quickjs": "0.20.1"
89
+ "node-addon-api": "^8.9.2",
90
+ "node-gyp-build": "^4.8.4"
91
91
  },
92
92
  "devDependencies": {
93
- "@swc/core": "^1.15.30",
93
+ "@mikrojs/registry": "0.20.2-next.20260907205905+8b6f502",
94
+ "@mikrojs/schema": "0.20.2-next.20260907205905+8b6f502",
95
+ "@swc/core": "^1.16.2",
94
96
  "@types/node": "^24.12.2",
95
- "esbuild": "^0.28.0",
96
- "terser": "^5.46.2",
97
- "@mikrojs/registry": "0.20.1",
98
- "@mikrojs/schema": "0.20.1"
97
+ "esbuild": "^0.28.2",
98
+ "terser": "^5.51.2"
99
99
  },
100
100
  "engines": {
101
101
  "node": ">=24.0.0"
@@ -58,18 +58,12 @@ export function makeCreateValue(native: NativeKvFns) {
58
58
  const onReadError = options?.onReadError ?? (() => undefined)
59
59
 
60
60
  function doGet(): unknown {
61
+ let v: unknown
62
+ // Only the native read is guarded: a throw from parse() or from the
63
+ // handler itself is a caller bug, and treating it as corruption would
64
+ // delete data that read back fine.
61
65
  try {
62
- const v = native.get(key)
63
- if (v === undefined) return hasInitialValue ? initialValue : undefined
64
- if (schema) {
65
- const result = parse(schema, v)
66
- if (!result.ok) {
67
- // Schema mismatch: data decoded fine, don't delete it
68
- return onReadError(result.error)
69
- }
70
- return result.value
71
- }
72
- return v
66
+ v = native.get(key)
73
67
  } catch (e) {
74
68
  // TypeError is the native's corruption marker (stored bytes that do
75
69
  // not decode): delete and heal. Anything else is a transient read
@@ -83,6 +77,14 @@ export function makeCreateValue(native: NativeKvFns) {
83
77
  }
84
78
  return onReadError(e)
85
79
  }
80
+ if (v === undefined) return hasInitialValue ? initialValue : undefined
81
+ if (schema) {
82
+ const result = parse(schema, v)
83
+ // Schema mismatch: data decoded fine, don't delete it
84
+ if (!result.ok) return onReadError(result.error)
85
+ return result.value
86
+ }
87
+ return v
86
88
  }
87
89
 
88
90
  function doSet(value: unknown) {
package/src/mik_abort.cpp CHANGED
@@ -52,8 +52,7 @@ static JSValue mik__abort_lazy_get(JSContext* ctx, JSValue this_val, int magic)
52
52
  * would surface a bare 'null'. Mirrors the module loader's
53
53
  * missing-builtin fallback. */
54
54
  if (!JS_HasException(ctx)) {
55
- JS_ThrowReferenceError(ctx,
56
- "Builtin module 'mikro/abort' is not available in this build");
55
+ JS_ThrowTypeError(ctx, "Failed to resolve module specifier 'mikro/abort'");
57
56
  }
58
57
  JS_FreeValue(ctx, global_obj);
59
58
  return JS_EXCEPTION;
@@ -3,7 +3,6 @@
3
3
  #include <cstring>
4
4
  #include <string>
5
5
  #include <unistd.h>
6
- #include <vector>
7
6
 
8
7
  #include <quickjs.h>
9
8
 
@@ -122,41 +121,6 @@ static std::string mik__format(JSContext* ctx, int argc, JSValue* argv, bool col
122
121
  return result;
123
122
  }
124
123
 
125
- /* ── Error formatting (port of console.ts formatError) ───────────── */
126
-
127
- static std::string mik__format_error(JSContext* ctx, JSValue error) {
128
- JSValue stack_val = JS_GetPropertyStr(ctx, error, "stack");
129
- JSValue msg_val = JS_GetPropertyStr(ctx, error, "message");
130
-
131
- const char* stack = JS_ToCString(ctx, stack_val);
132
- const char* msg = JS_ToCString(ctx, msg_val);
133
-
134
- std::string result;
135
- if (stack && msg && strstr(stack, msg)) {
136
- /* Stack already includes message */
137
- result = stack;
138
- } else {
139
- JSValue name_val = JS_GetPropertyStr(ctx, error, "name");
140
- const char* name = JS_ToCString(ctx, name_val);
141
- result = name ? name : "Error";
142
- result += ": ";
143
- result += msg ? msg : "";
144
- if (stack) {
145
- result += "\n";
146
- result += stack;
147
- }
148
- if (name) JS_FreeCString(ctx, name);
149
- JS_FreeValue(ctx, name_val);
150
- }
151
-
152
- if (stack) JS_FreeCString(ctx, stack);
153
- if (msg) JS_FreeCString(ctx, msg);
154
- JS_FreeValue(ctx, stack_val);
155
- JS_FreeValue(ctx, msg_val);
156
-
157
- return result;
158
- }
159
-
160
124
  /* ── Console methods ─────────────────────────────────────────────── */
161
125
 
162
126
  static JSValue mik__console_log(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
@@ -205,24 +169,9 @@ static JSValue mik__console_error_warn(JSContext* ctx, JSValue this_val, int arg
205
169
  int magic) {
206
170
  uint8_t msg_type = magic == 0 ? MIK_MSG_ERROR : MIK_MSG_WARN;
207
171
 
208
- /* Map Error objects to their stack traces */
209
- std::vector<JSValue> mapped;
210
- mapped.reserve(argc);
211
-
212
- for (int i = 0; i < argc; i++) {
213
- if (JS_IsError(argv[i])) {
214
- std::string err_str = mik__format_error(ctx, argv[i]);
215
- mapped.push_back(JS_NewString(ctx, err_str.c_str()));
216
- } else {
217
- mapped.push_back(JS_DupValue(ctx, argv[i]));
218
- }
219
- }
220
-
221
- std::string output = mik__format(ctx, argc, mapped.data(), true);
222
-
223
- for (auto& v : mapped) {
224
- JS_FreeValue(ctx, v);
225
- }
172
+ /* Error arguments render through inspect: name, message, extra fields,
173
+ * stack and cause chain. Same output as console.log for the same value. */
174
+ std::string output = mik__format(ctx, argc, argv, true);
226
175
 
227
176
  if (mik__repl_is_protocol_mode()) {
228
177
  mik__repl_proto_send_output(msg_type, output.c_str(), output.size());
@@ -236,6 +185,152 @@ static JSValue mik__console_error_warn(JSContext* ctx, JSValue this_val, int arg
236
185
 
237
186
  /* ── Uncaught error reporting ─────────────────────────────────────── */
238
187
 
188
+ /* Bounded stack-buffer writer for the uncaught path: never touches the
189
+ * C++ heap (see mik__report_uncaught). Silently truncates when full. */
190
+ struct MIKBoundedBuf {
191
+ char* buf;
192
+ size_t cap;
193
+ size_t pos = 0;
194
+ void append(const char* s, size_t len) {
195
+ if (pos >= cap - 1) return;
196
+ size_t avail = cap - 1 - pos;
197
+ size_t n = len < avail ? len : avail;
198
+ memcpy(buf + pos, s, n);
199
+ pos += n;
200
+ }
201
+ void cstr(const char* s) {
202
+ if (s) append(s, strlen(s));
203
+ }
204
+ };
205
+
206
+ /* One primitive as a log token (strings quoted). Objects print as
207
+ * [object]: nested inspection would need the C++ heap. */
208
+ static void mik__append_primitive(JSContext* ctx, JSValue v, MIKBoundedBuf& out) {
209
+ if (JS_IsObject(v)) {
210
+ out.cstr("[object]");
211
+ return;
212
+ }
213
+ /* Allocation-free spellings first: a null rejection is how QuickJS
214
+ * reports OOM, so this must work with no heap at all */
215
+ if (JS_IsNull(v)) {
216
+ out.cstr("null");
217
+ return;
218
+ }
219
+ if (JS_IsUndefined(v)) {
220
+ out.cstr("undefined");
221
+ return;
222
+ }
223
+ if (JS_IsBool(v)) {
224
+ out.cstr(JS_ToBool(ctx, v) ? "true" : "false");
225
+ return;
226
+ }
227
+ if (JS_VALUE_GET_TAG(v) == JS_TAG_INT) {
228
+ char num[16];
229
+ int n = snprintf(num, sizeof(num), "%d", JS_VALUE_GET_INT(v));
230
+ if (n > 0) out.append(num, (size_t)n);
231
+ return;
232
+ }
233
+ bool quote = JS_IsString(v);
234
+ /* JS-heap allocation only; returns NULL on OOM instead of throwing */
235
+ const char* s = JS_ToCString(ctx, v);
236
+ if (!s) {
237
+ /* Symbols reject ToString */
238
+ JS_FreeValue(ctx, JS_GetException(ctx));
239
+ out.cstr("[primitive]");
240
+ return;
241
+ }
242
+ if (quote) out.cstr("'");
243
+ out.cstr(s);
244
+ if (quote) out.cstr("'");
245
+ JS_FreeCString(ctx, s);
246
+ }
247
+
248
+ /* "Name: message { extra: fields }" then the stack frames, `indent`
249
+ * prefixed to every frame line. Also fits a plain object (a Result error
250
+ * as a panic cause has name/message and no stack). */
251
+ static void mik__append_error_head(JSContext* ctx, JSValue exc, const char* indent, MIKBoundedBuf& out) {
252
+ JSValue name_val = JS_GetPropertyStr(ctx, exc, "name");
253
+ JSValue msg_val = JS_GetPropertyStr(ctx, exc, "message");
254
+ if (JS_IsException(name_val) || JS_IsException(msg_val)) {
255
+ JS_FreeValue(ctx, JS_GetException(ctx));
256
+ }
257
+ /* Non-string name/message count as absent (a native Result error has
258
+ * code + message and no name) */
259
+ const char* name = JS_IsString(name_val) ? JS_ToCString(ctx, name_val) : nullptr;
260
+ const char* emsg = JS_IsString(msg_val) ? JS_ToCString(ctx, msg_val) : nullptr;
261
+
262
+ if (name && name[0]) {
263
+ out.cstr(name);
264
+ if (emsg && emsg[0]) {
265
+ out.cstr(": ");
266
+ out.cstr(emsg);
267
+ }
268
+ } else if (emsg && emsg[0]) {
269
+ out.cstr(emsg);
270
+ } else {
271
+ out.cstr("[object]");
272
+ }
273
+
274
+ if (name) JS_FreeCString(ctx, name);
275
+ if (emsg) JS_FreeCString(ctx, emsg);
276
+ JS_FreeValue(ctx, name_val);
277
+ JS_FreeValue(ctx, msg_val);
278
+
279
+ /* Own enumerable fields beyond the standard ones: code, errno, path... */
280
+ JSPropertyEnum* ptab = nullptr;
281
+ uint32_t plen = 0;
282
+ if (JS_GetOwnPropertyNames(ctx, &ptab, &plen, exc, JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) ==
283
+ 0) {
284
+ bool first = true;
285
+ for (uint32_t i = 0; i < plen; i++) {
286
+ const char* key = JS_AtomToCString(ctx, ptab[i].atom);
287
+ if (key && strcmp(key, "name") != 0 && strcmp(key, "message") != 0 &&
288
+ strcmp(key, "stack") != 0 && strcmp(key, "cause") != 0) {
289
+ JSValue v = JS_GetProperty(ctx, exc, ptab[i].atom);
290
+ if (JS_IsException(v)) {
291
+ JS_FreeValue(ctx, JS_GetException(ctx));
292
+ } else {
293
+ out.cstr(first ? " { " : ", ");
294
+ first = false;
295
+ out.cstr(key);
296
+ out.cstr(": ");
297
+ mik__append_primitive(ctx, v, out);
298
+ }
299
+ JS_FreeValue(ctx, v);
300
+ }
301
+ if (key) JS_FreeCString(ctx, key);
302
+ JS_FreeAtom(ctx, ptab[i].atom);
303
+ }
304
+ if (!first) out.cstr(" }");
305
+ js_free(ctx, ptab);
306
+ } else {
307
+ JS_FreeValue(ctx, JS_GetException(ctx));
308
+ }
309
+
310
+ JSValue stack_val = JS_GetPropertyStr(ctx, exc, "stack");
311
+ if (JS_IsString(stack_val)) {
312
+ const char* stack = JS_ToCString(ctx, stack_val);
313
+ if (stack) {
314
+ const char* p = stack;
315
+ while (*p) {
316
+ const char* nl = strchr(p, '\n');
317
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
318
+ if (len) {
319
+ out.cstr("\n");
320
+ out.cstr(indent);
321
+ out.append(p, len);
322
+ }
323
+ if (!nl) break;
324
+ p = nl + 1;
325
+ }
326
+ JS_FreeCString(ctx, stack);
327
+ }
328
+ } else if (JS_IsException(stack_val)) {
329
+ JS_FreeValue(ctx, JS_GetException(ctx));
330
+ }
331
+ JS_FreeValue(ctx, stack_val);
332
+ }
333
+
239
334
  /* Dedup across report paths: one error can reach this function more than
240
335
  * once for a single failure. The sync throw path (mik_dump_error) can report
241
336
  * an error whose promise still sits in the deferred flush queue, e.g. an
@@ -275,77 +370,59 @@ bool mik__report_uncaught(JSContext* ctx, JSValue exc, bool in_promise) {
275
370
  * abort() — masking the real OOM the reporter was trying to surface.
276
371
  * Messages longer than the buffer get truncated, which beats
277
372
  * aborting. */
278
- char buf[512];
279
- size_t pos = 0;
280
- auto append = [&](const char* s, size_t len) {
281
- if (pos >= sizeof(buf) - 1) return;
282
- size_t avail = sizeof(buf) - 1 - pos;
283
- size_t n = len < avail ? len : avail;
284
- memcpy(buf + pos, s, n);
285
- pos += n;
286
- };
287
- auto append_cstr = [&](const char* s) {
288
- if (s) append(s, strlen(s));
289
- };
373
+ char buf[1024];
374
+ MIKBoundedBuf out{buf, sizeof(buf)};
290
375
 
291
- append_cstr(in_promise ? "Uncaught (in promise) " : "Uncaught ");
376
+ out.cstr(in_promise ? "Uncaught (in promise) " : "Uncaught ");
292
377
 
293
378
  if (JS_IsObject(exc)) {
294
- JSValue name_val = JS_GetPropertyStr(ctx, exc, "name");
295
- JSValue msg_val = JS_GetPropertyStr(ctx, exc, "message");
296
- const char* name = JS_ToCString(ctx, name_val);
297
- const char* emsg = JS_ToCString(ctx, msg_val);
298
-
299
- if (name && name[0]) {
300
- append_cstr(name);
301
- if (emsg && emsg[0]) {
302
- append_cstr(": ");
303
- append_cstr(emsg);
379
+ mik__append_error_head(ctx, exc, "", out);
380
+
381
+ /* Cause chain: a panic's cause is the Result error that triggered
382
+ * it, so this is usually the line that says what actually failed.
383
+ * Bounded depth; `seen` cuts cycles. */
384
+ static const char* const indents[] = {"", " ", " ", " ", " "};
385
+ void* seen[countof(indents)] = {JS_VALUE_GET_PTR(exc)};
386
+ JSValue cur = JS_DupValue(ctx, exc);
387
+ for (size_t level = 1; level < countof(indents); level++) {
388
+ JSValue cause = JS_GetPropertyStr(ctx, cur, "cause");
389
+ JS_FreeValue(ctx, cur);
390
+ cur = cause;
391
+ if (JS_IsException(cause)) {
392
+ JS_FreeValue(ctx, JS_GetException(ctx));
393
+ break;
304
394
  }
305
- } else if (emsg && emsg[0]) {
306
- append_cstr(emsg);
307
- } else {
308
- append_cstr("[object]");
309
- }
310
-
311
- if (name) JS_FreeCString(ctx, name);
312
- if (emsg) JS_FreeCString(ctx, emsg);
313
- JS_FreeValue(ctx, name_val);
314
- JS_FreeValue(ctx, msg_val);
315
-
316
- JSValue stack_val = JS_GetPropertyStr(ctx, exc, "stack");
317
- if (JS_IsString(stack_val)) {
318
- const char* stack = JS_ToCString(ctx, stack_val);
319
- if (stack && stack[0]) {
320
- append_cstr("\n");
321
- append_cstr(stack);
395
+ if (JS_IsUndefined(cause)) break;
396
+ out.cstr("\n");
397
+ out.cstr(indents[level]);
398
+ out.cstr("[cause]: ");
399
+ if (!JS_IsObject(cause)) {
400
+ mik__append_primitive(ctx, cause, out);
401
+ break;
402
+ }
403
+ bool cyclic = false;
404
+ for (size_t j = 0; j < level; j++) {
405
+ if (seen[j] == JS_VALUE_GET_PTR(cause)) cyclic = true;
406
+ }
407
+ if (cyclic) {
408
+ out.cstr("[Circular]");
409
+ break;
322
410
  }
323
- if (stack) JS_FreeCString(ctx, stack);
411
+ seen[level] = JS_VALUE_GET_PTR(cause);
412
+ mik__append_error_head(ctx, cause, indents[level], out);
324
413
  }
325
- JS_FreeValue(ctx, stack_val);
414
+ JS_FreeValue(ctx, cur);
326
415
  } else {
327
416
  /* Describe primitive rejections by type without going through
328
417
  * mik_inspect (which allocates a std::string). */
329
- if (JS_IsNull(exc)) {
330
- append_cstr("null");
331
- } else if (JS_IsUndefined(exc)) {
332
- append_cstr("undefined");
333
- } else if (JS_IsBool(exc)) {
334
- append_cstr(JS_ToBool(ctx, exc) ? "true" : "false");
335
- } else if (JS_IsNumber(exc)) {
336
- double d = 0;
337
- JS_ToFloat64(ctx, &d, exc);
338
- char num[32];
339
- int n = snprintf(num, sizeof(num), "%g", d);
340
- if (n > 0) append(num, (size_t)n);
341
- } else if (JS_IsString(exc)) {
418
+ if (JS_IsString(exc)) {
342
419
  const char* s = JS_ToCString(ctx, exc);
343
420
  if (s) {
344
- append_cstr(s);
421
+ out.cstr(s);
345
422
  JS_FreeCString(ctx, s);
346
423
  }
347
424
  } else {
348
- append_cstr("[primitive]");
425
+ mik__append_primitive(ctx, exc, out);
349
426
  }
350
427
 
351
428
  /* Primitive rejections lose the throw-site stack (the async
@@ -359,17 +436,17 @@ bool mik__report_uncaught(JSContext* ctx, JSValue exc, bool in_promise) {
359
436
  int n = snprintf(note, sizeof(note),
360
437
  "\n (primitive rejection, systemFree=%zuB at report time)",
361
438
  plat->get_free_system_mem());
362
- if (n > 0) append(note, (size_t)n);
439
+ if (n > 0) out.append(note, (size_t)n);
363
440
  }
364
441
  }
365
442
 
366
443
  if (mik__repl_is_protocol_mode()) {
367
- mik__repl_proto_send_output(MIK_MSG_ERROR, buf, pos);
444
+ mik__repl_proto_send_output(MIK_MSG_ERROR, buf, out.pos);
368
445
  return true;
369
446
  }
370
447
 
371
- append_cstr("\r\n");
372
- MIK_GetPlatform()->stderr_write(buf, pos);
448
+ out.cstr("\r\n");
449
+ MIK_GetPlatform()->stderr_write(buf, out.pos);
373
450
  return true;
374
451
  }
375
452
 
@@ -18,6 +18,8 @@ struct InspectOpts {
18
18
  bool colors;
19
19
  bool show_hidden;
20
20
  std::vector<void*> seen;
21
+ /* Nesting of [cause] rendering, capped like mik__report_uncaught */
22
+ int cause_level = 0;
21
23
  };
22
24
 
23
25
  /* ── Helpers ─────────────────────────────────────────────────────── */
@@ -85,6 +87,7 @@ static std::string quote_key(const char* key) {
85
87
  /* ── Forward declaration ─────────────────────────────────────────── */
86
88
 
87
89
  static std::string inspect_value(JSContext* ctx, JSValue value, InspectOpts& opts, int depth);
90
+ static std::string indent_continuation(const std::string& s);
88
91
 
89
92
  /* ── Custom inspect callback state (single-threaded JS) ──────────── */
90
93
 
@@ -263,7 +266,7 @@ static std::string inspect_array_items(JSContext* ctx, JSValue arr, uint32_t len
263
266
  if (JS_IsException(item)) {
264
267
  out += getter_threw(ctx, opts);
265
268
  } else {
266
- out += inspect_value(ctx, item, opts, depth);
269
+ out += indent_continuation(inspect_value(ctx, item, opts, depth));
267
270
  }
268
271
  JS_FreeValue(ctx, item);
269
272
  }
@@ -318,7 +321,7 @@ static std::string inspect_properties(JSContext* ctx, JSValue obj, InspectOpts&
318
321
  if (JS_IsException(val)) {
319
322
  out += getter_threw(ctx, opts);
320
323
  } else {
321
- out += inspect_value(ctx, val, opts, depth);
324
+ out += indent_continuation(inspect_value(ctx, val, opts, depth));
322
325
  }
323
326
  JS_FreeValue(ctx, val);
324
327
  }
@@ -474,6 +477,51 @@ static std::string inspect_regexp(JSContext* ctx, JSValue value, InspectOpts& op
474
477
  return stylize("/(?:)/", MIK_TOKEN_REGEXP, opts);
475
478
  }
476
479
 
480
+ /* Indent every line after the first by two spaces so a multi-line value
481
+ * (an error's stack frames, its own cause) nests visibly under its parent. */
482
+ static std::string indent_continuation(const std::string& s) {
483
+ std::string out;
484
+ out.reserve(s.size() + 8);
485
+ for (char c : s) {
486
+ out += c;
487
+ if (c == '\n') out += " ";
488
+ }
489
+ return out;
490
+ }
491
+
492
+ /* "\n [cause]: <value>" for an own cause, or "". Shared by Errors and
493
+ * plain objects, since Result errors carry cause as an ordinary field.
494
+ * The chain is what a log line exists for, so it renders past the depth
495
+ * limit; the caller keeps the parent on the seen list to bound cycles. */
496
+ static std::string inspect_cause(JSContext* ctx, JSValue value, InspectOpts& opts, int depth) {
497
+ JSValue cause = JS_GetPropertyStr(ctx, value, "cause");
498
+ if (JS_IsException(cause)) {
499
+ drain_exception(ctx);
500
+ return "";
501
+ }
502
+ /* Same cap as mik__report_uncaught; bounds recursion on the JS task stack */
503
+ static const int MAX_CAUSE_LEVEL = 4;
504
+ std::string out;
505
+ if (!JS_IsUndefined(cause)) {
506
+ out = "\n [cause]: ";
507
+ if (opts.cause_level >= MAX_CAUSE_LEVEL) {
508
+ out += TRUNCATOR;
509
+ } else {
510
+ opts.cause_level++;
511
+ out += indent_continuation(inspect_value(ctx, cause, opts, depth < 1 ? 1 : depth));
512
+ opts.cause_level--;
513
+ }
514
+ }
515
+ JS_FreeValue(ctx, cause);
516
+ return out;
517
+ }
518
+
519
+ /* Errors render as
520
+ * Name: message { extra: props }
521
+ * at frame (file:line:col)
522
+ * [cause]: <inspected cause>
523
+ * The header keeps name, message and every own enumerable field (code,
524
+ * errno, ...) on one line; the stack and the cause chain follow. */
477
525
  static std::string inspect_error(JSContext* ctx, JSValue value, InspectOpts& opts, int depth) {
478
526
  /* Get name and message */
479
527
  JSValue name_val = JS_GetPropertyStr(ctx, value, "name");
@@ -498,16 +546,40 @@ static std::string inspect_error(JSContext* ctx, JSValue value, InspectOpts& opt
498
546
  JS_FreeValue(ctx, name_val);
499
547
  JS_FreeValue(ctx, msg_val);
500
548
 
501
- /* Inspect extra properties (skip standard error keys) */
549
+ if (is_seen(opts, value)) return result + " [Circular]";
550
+ opts.seen.push_back(JS_VALUE_GET_PTR(value));
551
+
552
+ /* Inspect extra properties (skip standard error keys; cause is rendered
553
+ * below so it reads the same whether QuickJS or a panic defined it) */
502
554
  static const char* const error_keys[] = {"stack", "line", "column", "name",
503
555
  "message", "fileName", "lineNumber", "columnNumber",
504
- "number", "description"};
556
+ "number", "description", "cause"};
505
557
  std::string props =
506
558
  inspect_properties(ctx, value, opts, depth, error_keys, countof(error_keys));
507
559
  if (!props.empty()) {
508
560
  result += " { " + props + " }";
509
561
  }
510
562
 
563
+ /* QuickJS stacks are frame lines only (" at ..."), no header line */
564
+ JSValue stack_val = JS_GetPropertyStr(ctx, value, "stack");
565
+ if (JS_IsException(stack_val)) drain_exception(ctx);
566
+ if (JS_IsString(stack_val)) {
567
+ const char* stack = JS_ToCString(ctx, stack_val);
568
+ if (stack) {
569
+ std::string frames(stack);
570
+ JS_FreeCString(ctx, stack);
571
+ while (!frames.empty() && frames.back() == '\n') frames.pop_back();
572
+ if (!frames.empty()) {
573
+ result += "\n";
574
+ result += frames;
575
+ }
576
+ }
577
+ }
578
+ JS_FreeValue(ctx, stack_val);
579
+
580
+ result += inspect_cause(ctx, value, opts, depth);
581
+
582
+ opts.seen.pop_back();
511
583
  return result;
512
584
  }
513
585
 
@@ -603,9 +675,9 @@ static std::string inspect_map(JSContext* ctx, JSValue value, InspectOpts& opts,
603
675
 
604
676
  if (!first) items += ", ";
605
677
  first = false;
606
- items += inspect_value(ctx, key, opts, depth);
678
+ items += indent_continuation(inspect_value(ctx, key, opts, depth));
607
679
  items += " => ";
608
- items += inspect_value(ctx, val, opts, depth);
680
+ items += indent_continuation(inspect_value(ctx, val, opts, depth));
609
681
 
610
682
  JS_FreeValue(ctx, key);
611
683
  JS_FreeValue(ctx, val);
@@ -673,7 +745,7 @@ static std::string inspect_set(JSContext* ctx, JSValue value, InspectOpts& opts,
673
745
  JSValue val = JS_GetPropertyStr(ctx, step, "value");
674
746
  if (!first) items += ", ";
675
747
  first = false;
676
- items += inspect_value(ctx, val, opts, depth);
748
+ items += indent_continuation(inspect_value(ctx, val, opts, depth));
677
749
  JS_FreeValue(ctx, val);
678
750
  JS_FreeValue(ctx, step);
679
751
  }
@@ -746,11 +818,16 @@ static std::string inspect_object(JSContext* ctx, JSValue value, InspectOpts& op
746
818
 
747
819
  if (plen == 0) return "{}";
748
820
 
821
+ /* A cause field chains below the braces like an Error's, so a Result
822
+ * error wrapped around another keeps its whole chain readable */
823
+ static const char* const cause_key[] = {"cause"};
749
824
  opts.seen.push_back(JS_VALUE_GET_PTR(value));
750
- std::string props = inspect_properties(ctx, value, opts, depth);
825
+ std::string props = inspect_properties(ctx, value, opts, depth, cause_key, 1);
826
+ std::string result = props.empty() ? "{}" : "{ " + props + " }";
827
+ result += inspect_cause(ctx, value, opts, depth);
751
828
  opts.seen.pop_back();
752
829
 
753
- return "{ " + props + " }";
830
+ return result;
754
831
  }
755
832
 
756
833
  static std::string inspect_class(JSContext* ctx, JSValue value, const std::string& type_name,
@@ -354,10 +354,13 @@ bool MIKOtaClient::Enrollment(std::string* out_url, std::string* out_bearer) con
354
354
  char url[256] = {};
355
355
  char bearer[128] = {};
356
356
  if (!env_ || !env_->kv_get_str) return false;
357
- if (!env_->kv_get_str(env_->opaque, "ota.registry", url, sizeof(url)) || url[0] == '\0') {
357
+ /* A read that failed reads as "not enrolled", which only makes the client
358
+ * skip the round. Erring the other way would send a half-read update key. */
359
+ if (env_->kv_get_str(env_->opaque, "ota.registry", url, sizeof(url)) != MIK_OTA_KV_OK ||
360
+ url[0] == '\0') {
358
361
  return false;
359
362
  }
360
- if (!env_->kv_get_str(env_->opaque, "ota.updateKey", bearer, sizeof(bearer)) ||
363
+ if (env_->kv_get_str(env_->opaque, "ota.updateKey", bearer, sizeof(bearer)) != MIK_OTA_KV_OK ||
361
364
  bearer[0] == '\0') {
362
365
  return false;
363
366
  }
@@ -13,6 +13,11 @@ namespace mikrojs {
13
13
  /* Staging attempts for one url before a checksum is abandoned. */
14
14
  constexpr int32_t MIK__OTA_MAX_TRIES = 3;
15
15
 
16
+ /* The contract is 64 lowercase hex (see decline(), which enforces the format).
17
+ * Only the length is checked here, because a shorter checksum round-trips
18
+ * fine and the registry is free to use one. */
19
+ constexpr size_t MIK__OTA_CHECKSUM_MAX = 64;
20
+
16
21
  const char* mik__ota_outcome_to_str(MIKOtaApplyOutcome outcome) {
17
22
  switch (outcome) {
18
23
  case MIKOtaApplyOutcome::kStaged:
@@ -48,10 +53,23 @@ std::string url_digest(const std::string& url) {
48
53
 
49
54
  } // namespace
50
55
 
56
+ MIKOtaKvStatus MIKOtaStore::ReadStr(const char* key, char* out, size_t max_len) const {
57
+ if (!env_ || !env_->kv_get_str) return MIK_OTA_KV_ABSENT;
58
+ MIKOtaKvStatus status = env_->kv_get_str(env_->opaque, key, out, max_len);
59
+ if (status == MIK_OTA_KV_ERROR) read_failed_ = true;
60
+ return status;
61
+ }
62
+
63
+ MIKOtaKvStatus MIKOtaStore::ReadI32(const char* key, int32_t* out) const {
64
+ if (!env_ || !env_->kv_get_i32) return MIK_OTA_KV_ABSENT;
65
+ MIKOtaKvStatus status = env_->kv_get_i32(env_->opaque, key, out);
66
+ if (status == MIK_OTA_KV_ERROR) read_failed_ = true;
67
+ return status;
68
+ }
69
+
51
70
  bool MIKOtaStore::UrlMatches(const std::string& url) const {
52
- if (!env_ || !env_->kv_get_str) return false;
53
71
  char buf[17] = {};
54
- if (!env_->kv_get_str(env_->opaque, "ota.url", buf, sizeof(buf))) return false;
72
+ if (ReadStr("ota.url", buf, sizeof(buf)) != MIK_OTA_KV_OK) return false;
55
73
  return url_digest(url) == buf;
56
74
  }
57
75
 
@@ -61,9 +79,8 @@ void MIKOtaStore::SetUrl(const std::string& url) {
61
79
  }
62
80
 
63
81
  std::string MIKOtaStore::GetAttempt() const {
64
- if (!env_ || !env_->kv_get_str) return "";
65
82
  char buf[128] = {};
66
- if (env_->kv_get_str(env_->opaque, "ota.att", buf, sizeof(buf))) {
83
+ if (ReadStr("ota.att", buf, sizeof(buf)) == MIK_OTA_KV_OK) {
67
84
  return buf;
68
85
  }
69
86
  return "";
@@ -75,12 +92,14 @@ void MIKOtaStore::SetAttempt(const std::string& checksum) {
75
92
  }
76
93
 
77
94
  int32_t MIKOtaStore::GetTries() const {
78
- if (!env_ || !env_->kv_get_i32) return 0;
79
95
  int32_t val = 0;
80
- if (env_->kv_get_i32(env_->opaque, "ota.tries", &val)) {
96
+ if (ReadI32("ota.tries", &val) == MIK_OTA_KV_OK) {
81
97
  return val;
82
98
  }
83
- return 0;
99
+ /* An unreadable budget counts as spent, which is what stops the attempt in
100
+ * apply_begin. Defaulting to "fully available" is the one mistake here that
101
+ * ends in a build reinstalling itself forever. */
102
+ return read_failed_ ? MIK__OTA_MAX_TRIES : 0;
84
103
  }
85
104
 
86
105
  void MIKOtaStore::SetTries(int32_t n) {
@@ -89,9 +108,8 @@ void MIKOtaStore::SetTries(int32_t n) {
89
108
  }
90
109
 
91
110
  std::string MIKOtaStore::GetBad() const {
92
- if (!env_ || !env_->kv_get_str) return "";
93
111
  char buf[128] = {};
94
- if (env_->kv_get_str(env_->opaque, "ota.bad", buf, sizeof(buf))) {
112
+ if (ReadStr("ota.bad", buf, sizeof(buf)) == MIK_OTA_KV_OK) {
95
113
  return buf;
96
114
  }
97
115
  return "";
@@ -103,9 +121,8 @@ void MIKOtaStore::SetBad(const std::string& checksum) {
103
121
  }
104
122
 
105
123
  bool MIKOtaStore::GetInFlight() const {
106
- if (!env_ || !env_->kv_get_i32) return false;
107
124
  int32_t val = 0;
108
- if (env_->kv_get_i32(env_->opaque, "ota.inflight", &val)) {
125
+ if (ReadI32("ota.inflight", &val) == MIK_OTA_KV_OK) {
109
126
  return val == 1;
110
127
  }
111
128
  return false;
@@ -117,16 +134,15 @@ void MIKOtaStore::SetInFlight(bool in_flight) {
117
134
  }
118
135
 
119
136
  bool MIKOtaStore::GetDecline(MIKOtaDeclineRecord* out) const {
120
- if (!env_ || !env_->kv_get_str) return false;
121
- if (!env_->kv_get_str(env_->opaque, "ota.declined", out->checksum, sizeof(out->checksum)) ||
137
+ if (ReadStr("ota.declined", out->checksum, sizeof(out->checksum)) != MIK_OTA_KV_OK ||
122
138
  out->checksum[0] == '\0') {
123
139
  return false;
124
140
  }
125
- if (!env_->kv_get_str(env_->opaque, "ota.declReason", out->reason, sizeof(out->reason)) ||
141
+ if (ReadStr("ota.declReason", out->reason, sizeof(out->reason)) != MIK_OTA_KV_OK ||
126
142
  out->reason[0] == '\0') {
127
143
  return false;
128
144
  }
129
- if (!env_->kv_get_str(env_->opaque, "ota.declDetail", out->detail, sizeof(out->detail))) {
145
+ if (ReadStr("ota.declDetail", out->detail, sizeof(out->detail)) != MIK_OTA_KV_OK) {
130
146
  out->detail[0] = '\0';
131
147
  }
132
148
  return true;
@@ -181,6 +197,18 @@ bool mik__ota_parse_offer(const char* url, const char* checksum, int64_t size, b
181
197
  return false;
182
198
  }
183
199
 
200
+ /* ota.att and ota.bad read back through a 128-byte buffer, and a stored
201
+ * value too long to decode reports a failed read, not an absent one. That
202
+ * stops every later offer, including a good one, and nothing rewrites the
203
+ * key. Bound it here, at the one place untrusted registry fields become an
204
+ * offer, rather than letting an unreadable value into the store. */
205
+ if (strlen(checksum) > MIK__OTA_CHECKSUM_MAX) {
206
+ if (out_warn_reason) {
207
+ *out_warn_reason = "checksum too long";
208
+ }
209
+ return false;
210
+ }
211
+
184
212
  if (size <= 0) {
185
213
  if (out_warn_reason) {
186
214
  *out_warn_reason = "invalid size";
@@ -202,7 +230,10 @@ MIKOtaReconcileOutcome mik__ota_policy_reconcile(const MIKOtaEnv* env) {
202
230
  MIKOtaStore store(env);
203
231
  if (store.GetInFlight()) {
204
232
  store.SetInFlight(false);
205
- } else {
233
+ } else if (!store.ReadFailed()) {
234
+ /* Only a boot we can prove was clean hands the budget back. An
235
+ * unreadable flag reads as "not in flight", and the attempt it would
236
+ * forget is the one that just took the device down. */
206
237
  store.SetTries(0);
207
238
  }
208
239
  MIKOtaReconcileOutcome outcome = {};
@@ -345,7 +376,23 @@ bool mik__ota_policy_apply_begin(const MIKOtaEnv* env, const MIKOtaOffer& offer,
345
376
 
346
377
  // (d) device has given up on this build
347
378
  MIKOtaStore store(env);
348
- if (!offer.checksum.empty() && offer.checksum == store.GetBad()) {
379
+ std::string bad = store.GetBad();
380
+ bool url_matches = store.UrlMatches(offer.url);
381
+ std::string attempt = store.GetAttempt();
382
+
383
+ // Read first, then decide: every value above reads as absent when the store
384
+ // could not answer, and each of those defaults grants the attempt
385
+ // something: no abandoned build, a url that never matched (a fresh
386
+ // budget). A store this device cannot read is one it has no business
387
+ // re-keying, and the heap pressure behind the failure is no state to start
388
+ // a download in anyway. Nothing is written here, so the next boot decides
389
+ // on the real values.
390
+ if (store.ReadFailed()) {
391
+ if (out_outcome) *out_outcome = MIKOtaApplyOutcome::kExhausted;
392
+ return true;
393
+ }
394
+
395
+ if (!offer.checksum.empty() && offer.checksum == bad) {
349
396
  if (out_outcome) *out_outcome = MIKOtaApplyOutcome::kAbandoned;
350
397
  return true;
351
398
  }
@@ -354,7 +401,7 @@ bool mik__ota_policy_apply_begin(const MIKOtaEnv* env, const MIKOtaOffer& offer,
354
401
  // attempts against this exact build at this exact url; the checksum is not
355
402
  // abandoned, because everything counted here is transient by construction
356
403
  // (a corrupt build is abandoned in apply_end instead).
357
- if (!store.UrlMatches(offer.url) || offer.checksum != store.GetAttempt()) {
404
+ if (!url_matches || offer.checksum != attempt) {
358
405
  store.SetUrl(offer.url);
359
406
  store.SetAttempt(offer.checksum);
360
407
  store.SetTries(0);
package/src/mikrojs.cpp CHANGED
@@ -1211,20 +1211,33 @@ int MIK_RunEntryErr(MIKRuntime* mik_rt, const char* entry, char* err_buf, size_t
1211
1211
  rejected = true;
1212
1212
  }
1213
1213
  }
1214
- if (failed && err_buf && err_buf_size > 0) {
1215
- /* Capture the failure's string form for the caller. Without an
1216
- * err_buf the pending exception is intentionally left on ctx,
1217
- * matching MIK_RunEntry's historical behavior. */
1214
+ if (failed) {
1218
1215
  JSValue exc = rejected ? JS_PromiseResult(ctx, result) : JS_GetException(ctx);
1219
- const char* msg = JS_ToCString(ctx, exc);
1220
- if (msg) {
1221
- snprintf(err_buf, err_buf_size, "%s", msg);
1222
- JS_FreeCString(ctx, msg);
1223
- } else {
1224
- /* Stringifying the exception itself threw (e.g. OOM) — drop
1225
- * the secondary exception so it can't leak into later evals. */
1226
- JSValue stray = JS_GetException(ctx);
1227
- JS_FreeValue(ctx, stray);
1216
+ if (err_buf && err_buf_size > 0) {
1217
+ /* Capture the failure's string form for the caller. */
1218
+ const char* msg = JS_ToCString(ctx, exc);
1219
+ if (msg) {
1220
+ snprintf(err_buf, err_buf_size, "%s", msg);
1221
+ JS_FreeCString(ctx, msg);
1222
+ } else {
1223
+ /* Stringifying the exception itself threw (e.g. OOM) — drop
1224
+ * the secondary exception so it can't leak into later evals. */
1225
+ JSValue stray = JS_GetException(ctx);
1226
+ JS_FreeValue(ctx, stray);
1227
+ }
1228
+ }
1229
+ /* A synchronous throw (a missing import, a syntax error) exists only
1230
+ * as the pending exception consumed above, so report it here the way
1231
+ * MIK_Loop reports one it finds pending: handler, dump, stop. Left
1232
+ * unreported, the device idles in silence. A rejected eval promise is
1233
+ * still queued for the loop's rejection flush, which reports it the
1234
+ * same way. */
1235
+ if (!rejected) {
1236
+ if (mik_rt->error_handler_fn) {
1237
+ mik_rt->error_handler_fn(ctx, exc, mik_rt->error_handler_opaque);
1238
+ }
1239
+ mik_dump_error1(ctx, exc);
1240
+ MIK_Stop(mik_rt);
1228
1241
  }
1229
1242
  JS_FreeValue(ctx, exc);
1230
1243
  }
package/src/modules.cpp CHANGED
@@ -234,8 +234,9 @@ static JSModuleDef* mik_module_loader_inner(JSContext* ctx, const char* module_n
234
234
  }
235
235
  }
236
236
  /* Native module not found — this is a device-only module that isn't
237
- * available in the current build (e.g. running on desktop). */
238
- JS_ThrowReferenceError(ctx, "Native module '%s' is not available", module_name);
237
+ * available in the current build (e.g. running on desktop). Same
238
+ * wording as every other unresolvable specifier (and browsers). */
239
+ JS_ThrowTypeError(ctx, "Failed to resolve module specifier '%s'", module_name);
239
240
  return nullptr;
240
241
  }
241
242
 
@@ -265,8 +266,7 @@ static JSModuleDef* mik_module_loader_inner(JSContext* ctx, const char* module_n
265
266
  * returns JS_UNINITIALIZED from an empty exception slot, and
266
267
  * re-throwing that surfaced as a bare "[uninitialized]" error. */
267
268
  if (!JS_HasException(ctx)) {
268
- JS_ThrowReferenceError(ctx, "Builtin module '%s' is not available in this build",
269
- module_name);
269
+ JS_ThrowTypeError(ctx, "Failed to resolve module specifier '%s'", module_name);
270
270
  }
271
271
  return NULL;
272
272
  }
@@ -996,7 +996,7 @@ JSValue mik__load_module_ns(JSContext* ctx, const char* importer, const char* sp
996
996
  JSValue p = JS_LoadModule(ctx, importer, specifier);
997
997
  if (JS_IsException(p)) {
998
998
  /* Propagate the loader's real error (e.g. a syntax error in a
999
- * virtual stub), not a generic not-available message. */
999
+ * virtual stub), not a generic resolution failure. */
1000
1000
  JS_FreeAtom(ctx, name);
1001
1001
  return p;
1002
1002
  }
@@ -1016,7 +1016,7 @@ JSValue mik__load_module_ns(JSContext* ctx, const char* importer, const char* sp
1016
1016
  }
1017
1017
  JS_FreeAtom(ctx, name);
1018
1018
  if (!m) {
1019
- return JS_ThrowReferenceError(ctx, "Native module '%s' is not available", specifier);
1019
+ return JS_ThrowTypeError(ctx, "Failed to resolve module specifier '%s'", specifier);
1020
1020
  }
1021
1021
  /* 5 == EVALUATED; quickjs.h documents the numbering but exports no enum. */
1022
1022
  if (require_evaluated && JS_GetModuleStatus(ctx, m) != 5) {