@mikrojs/native 0.12.0-next.9.g2e06437 → 0.13.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.
package/CMakeLists.txt CHANGED
@@ -81,7 +81,7 @@ endif()
81
81
  include(cmake/mikrojs_bytecode.cmake)
82
82
  mikrojs_generate_bytecode(
83
83
  RUNTIME_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime"
84
- MODULES cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
84
+ MODULES abort cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
85
85
  MODULE_PREFIX "mikro"
86
86
  SYMBOL_PREFIX "mikro"
87
87
  TARGET gen_bytecode
@@ -152,6 +152,7 @@ if(BUILD_TESTING)
152
152
  test/main.cpp
153
153
  test/runtime_test.cpp
154
154
  test/modules_test.cpp
155
+ test/virtual_modules_test.cpp
155
156
  test/text_encoding_test.cpp
156
157
  test/cbor_test.cpp
157
158
  test/abort_test.cpp
@@ -163,6 +164,7 @@ if(BUILD_TESTING)
163
164
  test/runtime_recycle_test.cpp
164
165
  test/udp_test.cpp
165
166
  test/observable_test.cpp
167
+ test/unhandled_rejection_test.cpp
166
168
  )
167
169
 
168
170
  target_link_libraries(mikrojs_tests PRIVATE mikrojs)
@@ -86,6 +86,13 @@ void MIK_FreeRuntime(MIKRuntime* mik_rt);
86
86
  * Emits no diagnostic output — callers decide whether and how to log. */
87
87
  int MIK_RunEntry(MIKRuntime* mik_rt, const char* entry);
88
88
 
89
+ /* MIK_RunEntry variant that, on -EFAULT, copies the thrown exception's
90
+ * string form (or the sync-rejected promise's rejection value) into
91
+ * err_buf so callers can surface the real failure instead of a generic
92
+ * "evaluation threw". err_buf may be NULL; when given it is always
93
+ * NUL-terminated (empty when no message could be captured). */
94
+ int MIK_RunEntryErr(MIKRuntime* mik_rt, const char* entry, char* err_buf, size_t err_buf_size);
95
+
89
96
  JSContext* MIK_GetJSContext(MIKRuntime* mik_rt);
90
97
  MIKRuntime* MIK_GetRuntime(JSContext* ctx);
91
98
  void MIK_SetFSBasePath(MIKRuntime* mik_rt, const char* base_path);
@@ -64,6 +64,15 @@ typedef struct MIKPlatform {
64
64
  * original 6 MAC bytes. The returned pointer must remain valid for the
65
65
  * lifetime of the platform. */
66
66
  const char* (*get_device_id)(void);
67
+ /** Reason the chip last reset, as a stable lowercase string. On ESP32
68
+ * this maps esp_reset_reason(): "power-on", "software", "panic",
69
+ * "watchdog", "interrupt-watchdog", "task-watchdog", "brownout",
70
+ * "deep-sleep", "external", "sdio", "usb", "jtag", "efuse",
71
+ * "power-glitch", "cpu-lockup", or "unknown". A clean restart()
72
+ * reports "software". Host platforms have no reset concept and return
73
+ * "unknown". The returned pointer must remain valid for the lifetime
74
+ * of the platform. */
75
+ const char* (*get_reset_reason)(void);
67
76
  } MIKPlatform;
68
77
 
69
78
  /* Log levels (matching ESP-IDF ESP_LOG_xxx values) */
@@ -48,6 +48,15 @@ struct MIKLoopConsumerEntry {
48
48
  MIKLoopDestroyFn destroy_fn;
49
49
  };
50
50
 
51
+ /* A promise that rejected without a handler, awaiting the end-of-turn
52
+ * unhandled-rejection check. Mirrors quickjs-libc's JSRejectedPromiseEntry:
53
+ * we hold a reference to both the promise (matched by identity on `handle`)
54
+ * and its reason (reported at flush time). */
55
+ struct MIKRejectedPromise {
56
+ JSValue promise;
57
+ JSValue reason;
58
+ };
59
+
51
60
  struct MIKRuntime {
52
61
  MIKRunOptions options;
53
62
  MIKConfig config;
@@ -83,10 +92,16 @@ struct MIKRuntime {
83
92
  MIKTimerRegistry* timers;
84
93
  std::vector<MIKNativeModuleEntry> native_modules;
85
94
  std::vector<MIKLoopConsumerEntry> loop_consumers;
86
- struct {
87
- JSValue promise_event_ctor;
88
- JSValue dispatch_event_func;
89
- } builtins;
95
+ /* Promises that rejected without a handler, awaiting the end-of-turn
96
+ * unhandled-rejection check. The host rejection tracker adds a promise
97
+ * here on `reject` and removes it on `handle`; whatever remains after a
98
+ * microtask drain (mik__execute_jobs) is a genuine unhandled rejection
99
+ * and gets reported. This deferral mirrors the HTML/WinterCG algorithm
100
+ * and is what prevents a transiently-unhandled promise (e.g. a module's
101
+ * evaluation promise, rejected before the import loader attaches its
102
+ * .then) from being reported. Entries hold dup'd references owned by this
103
+ * vector and freed when removed or flushed. */
104
+ std::vector<MIKRejectedPromise> pending_rejections;
90
105
  /* Shared prototype for Result objects ({ok, value} / {ok, error}) created
91
106
  * by mik__result_ok/mik__result_err and the native:result ok()/err()
92
107
  * functions. Holds .map/.mapErr/.andThen/.match/.orDefault/.orPanic +
@@ -167,6 +182,10 @@ JSValue mik_new_error(JSContext* ctx, int err);
167
182
  JSValue mik_throw_errno(JSContext* ctx, int err);
168
183
 
169
184
  void mik__execute_jobs(JSContext* ctx);
185
+ /* End-of-turn unhandled-rejection check; called after each microtask drain. */
186
+ void mik__flush_unhandled_rejections(JSContext* ctx);
187
+ /* Drop a promise from the pending-rejection queue without reporting it. */
188
+ void mik__forget_rejection(JSContext* ctx, JSValue promise);
170
189
  JSModuleDef* mik__load_builtin(JSContext* ctx, const char* name);
171
190
  int mik__load_file(JSContext* ctx, DynBuf* dbuf, const char* filename);
172
191
  void mik__resolve_fs_path(JSContext* ctx, const char* module_name, char* out, size_t out_size);
@@ -221,7 +240,10 @@ void mik__stdin_consume(JSContext* ctx);
221
240
 
222
241
  /* Console global (mik_console.cpp) */
223
242
  void mik__console_init(JSContext* ctx, JSValue global_obj);
224
- void mik__report_uncaught(JSContext* ctx, JSValue exc, bool in_promise = false);
243
+ /* Reports an uncaught error/rejection. Dedups by error-object identity:
244
+ * returns true if it reported, false if this same object was already
245
+ * reported. */
246
+ bool mik__report_uncaught(JSContext* ctx, JSValue exc, bool in_promise = false);
225
247
 
226
248
  /* Inspect (mik_inspect.cpp) */
227
249
  std::string mik_inspect(JSContext* ctx, JSValue value, int depth = 2, bool colors = false,
@@ -313,6 +335,11 @@ void mik__repl_set_paused(bool paused);
313
335
  * Device replies with zero-or-more MIK_MSG_FS_CHUNK frames followed by
314
336
  * MIK_MSG_OK on EOF, or MIK_MSG_ERR if the path can't be opened. */
315
337
  #define MIK_CMD_FS_GET 0x2B
338
+ /* Clear the on-device log files (log.txt + log.txt.1). No payload.
339
+ * Device suspends the logger, deletes both files, reopens a fresh
340
+ * log.txt, and replies MIK_MSG_OK. No-op (still OK) when file logging
341
+ * is disabled. */
342
+ #define MIK_CMD_LOG_RESET 0x2C
316
343
 
317
344
  #define MIK_CMD_CONFIG_LIST 0x40
318
345
  #define MIK_CMD_CONFIG_SET 0x41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.12.0-next.9.g2e06437",
3
+ "version": "0.13.0",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -80,7 +80,7 @@
80
80
  "cmake-js": "^8.0.0",
81
81
  "node-addon-api": "^8.7.0",
82
82
  "node-gyp-build": "^4.8.4",
83
- "@mikrojs/quickjs": "0.12.0-next.9.g2e06437"
83
+ "@mikrojs/quickjs": "0.13.0"
84
84
  },
85
85
  "devDependencies": {
86
86
  "@swc/core": "^1.15.30",
@@ -0,0 +1,120 @@
1
+ /* Minimal AbortController / AbortSignal implementation.
2
+ *
3
+ * This is a lightweight subset of the WHATWG DOM spec: just enough
4
+ * for fetch timeouts and cooperative cancellation. No EventTarget
5
+ * dependency: listeners are stored in a simple array.
6
+ *
7
+ * Evaluating this module installs AbortController, AbortSignal,
8
+ * AbortError, and TimeoutError on globalThis. It is loaded lazily by
9
+ * the native lazy getters in mik_abort.cpp on first access to any of
10
+ * the four globals; shipping it as precompiled bytecode keeps the
11
+ * QuickJS parser out of the install path (parsing at runtime caused
12
+ * OOM on low-memory chips).
13
+ */
14
+
15
+ class AbortError extends Error {
16
+ constructor(message?: string) {
17
+ super(message || 'The operation was aborted')
18
+ }
19
+ get name() {
20
+ return 'AbortError'
21
+ }
22
+ }
23
+
24
+ class TimeoutError extends Error {
25
+ constructor(message?: string) {
26
+ super(message || 'The operation timed out')
27
+ }
28
+ get name() {
29
+ return 'TimeoutError'
30
+ }
31
+ }
32
+
33
+ const _a: unique symbol = Symbol()
34
+ const _r: unique symbol = Symbol()
35
+ const _l: unique symbol = Symbol()
36
+ const _s: unique symbol = Symbol()
37
+
38
+ function doAbort(signal: AbortSignal, reason: unknown): void {
39
+ if (signal[_a]) return
40
+ signal[_a] = true
41
+ signal[_r] = reason
42
+ if (typeof signal.onabort === 'function') signal.onabort()
43
+ for (const fn of signal[_l]) fn()
44
+ }
45
+
46
+ class AbortSignal {
47
+ [_a] = false;
48
+ [_r]: unknown = undefined;
49
+ [_l]: (() => void)[] = []
50
+ onabort: (() => void) | null = null
51
+
52
+ get aborted(): boolean {
53
+ return this[_a]
54
+ }
55
+ get reason(): unknown {
56
+ return this[_r]
57
+ }
58
+ throwIfAborted(): void {
59
+ if (this[_a]) throw this[_r]
60
+ }
61
+ addEventListener(type: 'abort', fn: () => void): void {
62
+ if (type === 'abort' && typeof fn === 'function') this[_l].push(fn)
63
+ }
64
+ removeEventListener(type: 'abort', fn: () => void): void {
65
+ if (type === 'abort') this[_l] = this[_l].filter((f) => f !== fn)
66
+ }
67
+ static abort(reason?: unknown): AbortSignal {
68
+ const s = new AbortSignal()
69
+ doAbort(s, reason !== undefined ? reason : new AbortError())
70
+ return s
71
+ }
72
+ static timeout(ms: number): AbortSignal {
73
+ const s = new AbortSignal()
74
+ setTimeout(() => doAbort(s, new TimeoutError()), ms)
75
+ return s
76
+ }
77
+ static any(signals: AbortSignal[]): AbortSignal {
78
+ const s = new AbortSignal()
79
+ for (const i of signals) {
80
+ if (i.aborted) {
81
+ doAbort(s, i.reason)
82
+ return s
83
+ }
84
+ }
85
+ for (const i of signals) {
86
+ i.addEventListener('abort', () => doAbort(s, i.reason))
87
+ }
88
+ return s
89
+ }
90
+ }
91
+
92
+ class AbortController {
93
+ [_s] = new AbortSignal()
94
+
95
+ get signal(): AbortSignal {
96
+ return this[_s]
97
+ }
98
+ abort(reason?: unknown): void {
99
+ doAbort(this[_s], reason !== undefined ? reason : new AbortError())
100
+ }
101
+ }
102
+
103
+ /* Object.defineProperties, NOT Object.assign: when this module is
104
+ * evaluated via a direct `import 'mikro/abort'` (it is in the builtins
105
+ * table), the four lazy getters installed by mik_abort.cpp are still
106
+ * present as setter-less accessors, and Object.assign's [[Set]] would
107
+ * throw "no setter for property". Defining replaces the accessors with
108
+ * plain data properties, matching what the lazy-getter path produces. */
109
+ const descriptor = (value: unknown) => ({
110
+ value,
111
+ writable: true,
112
+ enumerable: true,
113
+ configurable: true,
114
+ })
115
+ Object.defineProperties(globalThis, {
116
+ AbortError: descriptor(AbortError),
117
+ TimeoutError: descriptor(TimeoutError),
118
+ AbortSignal: descriptor(AbortSignal),
119
+ AbortController: descriptor(AbortController),
120
+ })
@@ -34,7 +34,7 @@ declare module 'native:observable' {
34
34
  }
35
35
 
36
36
  declare module 'native:sys' {
37
- import type {JsMemoryUsage} from './sys/types.js'
37
+ import type {JsMemoryUsage, ResetReason} from './sys/types.js'
38
38
  export function evalScript(code: string): Promise<{value: unknown}>
39
39
  export function memoryUsage(): {
40
40
  heapUsed: number
@@ -67,6 +67,7 @@ declare module 'native:sys' {
67
67
  }
68
68
  export const firmware: {hash: string; date: string; idfVersion: string | undefined}
69
69
  export const deviceId: string
70
+ export const resetReason: ResetReason
70
71
  }
71
72
 
72
73
  declare module 'native:fs' {
@@ -37,6 +37,8 @@ export const firmware = native.firmware
37
37
 
38
38
  export const deviceId: string = native.deviceId
39
39
 
40
+ export const resetReason = native.resetReason
41
+
40
42
  export function restart(): never {
41
43
  return native.restart() as never
42
44
  }
@@ -73,7 +73,10 @@ export interface JsMemoryUsage {
73
73
  arrayCount: number
74
74
  /** Total element slots across all fast arrays */
75
75
  fastArrayElements: number
76
- /** Bytes of backing storage for ArrayBuffer / TypedArray data */
76
+ /** Cumulative bytes deserialized via JS_ReadObject over this
77
+ * runtime's lifetime (bytecode of imported modules, builtins, and
78
+ * bjson). A monotonic counter, NOT live memory — it never goes
79
+ * down, so don't read it as retained ArrayBuffer storage. */
77
80
  binaryObjectSize: number
78
81
  }
79
82
 
@@ -122,6 +125,29 @@ export declare const firmware: {
122
125
  readonly idfVersion: string | undefined
123
126
  }
124
127
 
128
+ /** Why the device last reset. A clean `restart()` reports `'software'`;
129
+ * a crash reports `'panic'`. Host/Node builds always report `'unknown'`. */
130
+ export type ResetReason =
131
+ | 'power-on'
132
+ | 'software'
133
+ | 'panic'
134
+ | 'watchdog'
135
+ | 'interrupt-watchdog'
136
+ | 'task-watchdog'
137
+ | 'brownout'
138
+ | 'deep-sleep'
139
+ | 'external'
140
+ | 'sdio'
141
+ | 'usb'
142
+ | 'jtag'
143
+ | 'efuse'
144
+ | 'power-glitch'
145
+ | 'cpu-lockup'
146
+ | 'unknown'
147
+
148
+ /** Reason the device last reset, determined once at boot. */
149
+ export declare const resetReason: ResetReason
150
+
125
151
  export declare function restart(): never
126
152
 
127
153
  /** Reason the device woke up this boot. Set after deep sleep (or first
@@ -30,6 +30,17 @@ interface Suite {
30
30
  const suites: Suite[] = []
31
31
  let currentSuite: Suite | null = null
32
32
  let heapBaseline = 0
33
+ /** Free system heap (bytes) at the baseline point, or 0 on the host (no
34
+ * system heap). Reference point for the "peak" figure: baseline free minus
35
+ * the run's low-water is the most the suite needed at once. Recaptured after
36
+ * each suite's beforeAll, in step with heapBaseline, so warmup is excluded
37
+ * from the figure. */
38
+ let sysFreeStart = 0
39
+ /** Lowest free system heap (bytes) sampled (post-gc) this run, or 0 on the
40
+ * host. The suite's closest sampled approach to OOM. The module re-inits
41
+ * for each test file (fresh runtime per file), so this is a true per-file
42
+ * figure, not a process-lifetime watermark shared across files. */
43
+ let sysFreeFloor = 0
33
44
 
34
45
  /**
35
46
  * Recapture the heap baseline. Called by the harness after each suite's
@@ -43,7 +54,15 @@ let heapBaseline = 0
43
54
  async function captureHeapBaseline(): Promise<void> {
44
55
  await Promise.resolve()
45
56
  gc()
46
- heapBaseline = memoryUsage().heapUsed
57
+ const {heapUsed, systemFree} = memoryUsage()
58
+ heapBaseline = heapUsed
59
+ // Recapture the system-heap baseline and reset the per-file low-water in
60
+ // step with heapBaseline, so warmup (module loads, TLS, wifi) is excluded
61
+ // from sysUsed the same way it is from heapDelta.
62
+ if (systemFree > 0) {
63
+ sysFreeStart = systemFree
64
+ sysFreeFloor = systemFree
65
+ }
47
66
  }
48
67
 
49
68
  function newSuite(name: string, flags: {skip?: boolean; only?: boolean; todo?: boolean}): Suite {
@@ -363,6 +382,8 @@ type TestEvent =
363
382
  d: number
364
383
  hb?: number
365
384
  ha?: number
385
+ su?: number
386
+ sf?: number
366
387
  tb?: number
367
388
  ta?: number
368
389
  pb?: number
@@ -401,7 +422,10 @@ function emitHeap(): void {
401
422
  gc()
402
423
  const mem = memoryUsage()
403
424
  const evt: TestEvent = {e: 8, u: mem.heapUsed, t: mem.heapTotal}
404
- if (mem.systemFree > 0) evt.f = mem.systemFree
425
+ if (mem.systemFree > 0) {
426
+ evt.f = mem.systemFree
427
+ if (sysFreeFloor === 0 || mem.systemFree < sysFreeFloor) sysFreeFloor = mem.systemFree
428
+ }
405
429
  if (mem.systemMinFree > 0) evt.mf = mem.systemMinFree
406
430
  emit(evt)
407
431
  }
@@ -420,7 +444,14 @@ async function run(): Promise<void> {
420
444
  // warmup costs (module loads, TLS lazy-init, wifi connection) get
421
445
  // folded into the baseline automatically.
422
446
  gc()
423
- heapBaseline = memoryUsage().heapUsed
447
+ // Destructure to primitives so the live memoryUsage() object isn't held
448
+ // while heapBaseline is captured (it would otherwise be counted in it).
449
+ const {heapUsed: startHeap, systemFree: startFree} = memoryUsage()
450
+ heapBaseline = startHeap
451
+ if (startFree > 0) {
452
+ sysFreeStart = startFree
453
+ sysFreeFloor = startFree
454
+ }
424
455
  const timersBefore = activeTimers()
425
456
  const pendingBefore = pendingHttpCount()
426
457
 
@@ -569,11 +600,23 @@ async function run(): Promise<void> {
569
600
  }
570
601
 
571
602
  gc()
572
- const heapAfter = memoryUsage().heapUsed
603
+ // Destructure to primitives (see baseline capture above) so the live
604
+ // memoryUsage() object isn't held while we build the run_done event.
605
+ const {heapUsed: heapAfter, systemFree: endFree} = memoryUsage()
606
+ if (endFree > 0 && (sysFreeFloor === 0 || endFree < sysFreeFloor)) {
607
+ sysFreeFloor = endFree
608
+ }
573
609
  const timersAfter = activeTimers()
574
610
  const pendingAfter = pendingHttpCount()
575
611
 
576
- emit({
612
+ // Per-file system-heap figures. sysFreeFloor is the lowest free heap we
613
+ // sampled (post-gc) this run; sysUsed is how far free heap fell from the
614
+ // baseline to that low. They sum back to the baseline free, so "peak" and
615
+ // "min free" read as one story. Samples are taken between tests, so a
616
+ // transient peak inside a single test can dip below what sysFreeFloor saw.
617
+ const sysUsed = sysFreeStart > sysFreeFloor ? sysFreeStart - sysFreeFloor : 0
618
+
619
+ const doneEvt: TestEvent = {
577
620
  e: 6,
578
621
  p: passed,
579
622
  f: failed,
@@ -586,7 +629,12 @@ async function run(): Promise<void> {
586
629
  ta: timersAfter,
587
630
  pb: pendingBefore,
588
631
  pa: pendingAfter,
589
- })
632
+ }
633
+ if (sysFreeStart > 0) {
634
+ doneEvt.su = sysUsed
635
+ doneEvt.sf = sysFreeFloor
636
+ }
637
+ emit(doneEvt)
590
638
 
591
639
  // Signal the supervisor (if any) that this file is complete so it can
592
640
  // swap in the next runtime. No-op in non-test-harness contexts.
package/src/builtins.cpp CHANGED
@@ -32,11 +32,16 @@ static JSModuleDef* mik__deserialize_builtin(JSContext* ctx, const char* name,
32
32
  JSValue obj = JS_ReadObject(ctx, data, data_size, JS_READ_OBJ_BYTECODE);
33
33
 
34
34
  if (JS_IsException(obj)) {
35
- platform->log(MIK_LOG_ERROR, "mikrojs", "Failed to deserialize bytecode for builtin '%s' (%u bytes)", name,
36
- data_size);
37
- /* Leave the JS exception on ctx so the caller can propagate the
38
- * real error (e.g. "Maximum call stack size exceeded") instead of
39
- * replacing it with a generic "not available" message. */
35
+ /* Peek the exception for the log, then re-throw it so the caller
36
+ * can still propagate the real error (e.g. "Maximum call stack
37
+ * size exceeded") instead of a generic "not available" message. */
38
+ JSValue exc = JS_GetException(ctx);
39
+ const char* msg = JS_ToCString(ctx, exc);
40
+ platform->log(MIK_LOG_ERROR, "mikrojs",
41
+ "Failed to deserialize bytecode for builtin '%s' (%u bytes): %s", name,
42
+ data_size, msg ? msg : "unknown error");
43
+ if (msg) JS_FreeCString(ctx, msg);
44
+ JS_Throw(ctx, exc);
40
45
  return NULL;
41
46
  }
42
47
 
package/src/mik_abort.cpp CHANGED
@@ -1,110 +1,32 @@
1
1
  #include <quickjs.h>
2
2
 
3
+ #include "mikrojs/private.h"
3
4
  #include "mikrojs/utils.h"
4
5
 
5
- /* Minimal AbortController / AbortSignal implementation.
6
+ /* AbortController / AbortSignal globals.
6
7
  *
7
- * This is a lightweight subset of the WHATWG DOM spec — just enough
8
- * for fetch timeouts and cooperative cancellation. No EventTarget
9
- * dependency: listeners are stored in a simple array.
8
+ * The implementation lives in runtime/abort/abort.ts and ships as
9
+ * precompiled bytecode in the builtins table ("mikro/abort").
10
+ * Evaluating that module installs AbortController, AbortSignal,
11
+ * AbortError, and TimeoutError on globalThis.
10
12
  *
11
- * Implements:
12
- * AbortSignal: aborted, reason, throwIfAborted(), onabort,
13
- * addEventListener("abort", fn), removeEventListener("abort", fn)
14
- * AbortSignal.abort(reason) pre-aborted signal
15
- * AbortSignal.timeout(ms) — auto-aborts after delay
16
- * AbortSignal.any(signals) — aborts when any input signal aborts
17
- * AbortController: signal, abort(reason)
18
- *
19
- * Lazy-initialized: the JS class hierarchy is compiled and installed
20
- * on first access to any of the four globals (AbortController,
21
- * AbortSignal, AbortError, TimeoutError).
13
+ * Lazy-initialized: this file installs lazy getters for the four
14
+ * globals; the first access evaluates the bytecode module. Bytecode
15
+ * (rather than JS source evaluated at runtime) keeps the QuickJS
16
+ * parser out of the install path the parse peak pushed low-memory
17
+ * chips into OOM when the heap was already near full.
22
18
  */
23
19
 
24
- static const char abort_js[] = R"JS(
25
- (function(g) {
26
- "use strict";
27
-
28
- class AbortError extends Error {
29
- constructor(message) { super(message || "The operation was aborted"); }
30
- get name() { return "AbortError"; }
31
- }
32
- class TimeoutError extends Error {
33
- constructor(message) { super(message || "The operation timed out"); }
34
- get name() { return "TimeoutError"; }
35
- }
36
-
37
- const _a = Symbol(), _r = Symbol(), _l = Symbol(), _s = Symbol();
38
- const ABORT_REASON = () => new AbortError();
39
-
40
- function doAbort(signal, reason) {
41
- if (signal[_a]) return;
42
- signal[_a] = true;
43
- signal[_r] = reason;
44
- if (typeof signal.onabort === "function") signal.onabort();
45
- for (const fn of signal[_l]) fn();
46
- }
47
-
48
- class AbortSignal {
49
- constructor() {
50
- this[_a] = false;
51
- this[_r] = undefined;
52
- this[_l] = [];
53
- this.onabort = null;
54
- }
55
- get aborted() { return this[_a]; }
56
- get reason() { return this[_r]; }
57
- throwIfAborted() { if (this[_a]) throw this[_r]; }
58
- addEventListener(type, fn) {
59
- if (type === "abort" && typeof fn === "function") this[_l].push(fn);
60
- }
61
- removeEventListener(type, fn) {
62
- if (type === "abort") this[_l] = this[_l].filter(f => f !== fn);
63
- }
64
- static abort(reason) {
65
- const s = new AbortSignal();
66
- doAbort(s, reason !== undefined ? reason : ABORT_REASON());
67
- return s;
68
- }
69
- static timeout(ms) {
70
- const s = new AbortSignal();
71
- setTimeout(() => doAbort(s, new TimeoutError()), ms);
72
- return s;
73
- }
74
- static any(signals) {
75
- const s = new AbortSignal();
76
- for (const i of signals) {
77
- if (i.aborted) { doAbort(s, i.reason); return s; }
78
- }
79
- for (const i of signals) {
80
- i.addEventListener("abort", () => doAbort(s, i.reason));
81
- }
82
- return s;
83
- }
84
- }
85
-
86
- class AbortController {
87
- constructor() { this[_s] = new AbortSignal(); }
88
- get signal() { return this[_s]; }
89
- abort(reason) { doAbort(this[_s], reason !== undefined ? reason : ABORT_REASON()); }
90
- }
91
-
92
- g.AbortError = AbortError;
93
- g.TimeoutError = TimeoutError;
94
- g.AbortSignal = AbortSignal;
95
- g.AbortController = AbortController;
96
- })
97
- )JS";
98
-
99
- /* Names of the globals this module installs */
20
+ /* Names of the globals the abort module installs */
100
21
  static const char* const abort_global_names[] = {"AbortController", "AbortSignal", "AbortError",
101
22
  "TimeoutError"};
102
23
  static constexpr int ABORT_GLOBAL_COUNT = 4;
103
24
 
104
25
  /**
105
26
  * Lazy getter: on first access to any of the four globals, delete all
106
- * lazy getters, evaluate the JS to install real values, then return
107
- * the requested one. The magic parameter identifies which global.
27
+ * lazy getters, evaluate the bytecode module to install real values,
28
+ * then return the requested one. The magic parameter identifies which
29
+ * global.
108
30
  *
109
31
  * Signature must match JS_CFUNC_getter_magic: (ctx, this_val, magic).
110
32
  */
@@ -112,29 +34,40 @@ static JSValue mik__abort_lazy_get(JSContext* ctx, JSValue this_val, int magic)
112
34
 
113
35
  JSValue global_obj = JS_GetGlobalObject(ctx);
114
36
 
115
- /* Remove all four lazy getters so the JS assignment (g.X = Y) works */
37
+ /* Remove all four lazy getters so the module's globalThis
38
+ * assignments define plain properties */
116
39
  for (int i = 0; i < ABORT_GLOBAL_COUNT; i++) {
117
40
  JSAtom prop = JS_NewAtom(ctx, abort_global_names[i]);
118
41
  JS_DeleteProperty(ctx, global_obj, prop, 0);
119
42
  JS_FreeAtom(ctx, prop);
120
43
  }
121
44
 
122
- /* Evaluate and install the real globals */
123
- JSValue wrapper =
124
- JS_Eval(ctx, abort_js, sizeof(abort_js) - 1, "<abort>", JS_EVAL_TYPE_GLOBAL);
125
- if (JS_IsException(wrapper)) {
126
- JSValue exc = JS_GetException(ctx);
127
- JS_FreeValue(ctx, exc);
45
+ /* Evaluate the precompiled module; its side effect installs the
46
+ * globals. The module is synchronous, so the returned promise is
47
+ * already settled. */
48
+ JSModuleDef* m = mik__load_builtin(ctx, "mikro/abort");
49
+ if (m == NULL) {
50
+ /* A table miss returns NULL with no pending exception (only
51
+ * deserialize failures throw); without this guard the getter
52
+ * would surface a bare 'null'. Mirrors the module loader's
53
+ * missing-builtin fallback. */
54
+ if (!JS_HasException(ctx)) {
55
+ JS_ThrowReferenceError(ctx,
56
+ "Builtin module 'mikro/abort' is not available in this build");
57
+ }
128
58
  JS_FreeValue(ctx, global_obj);
129
- return JS_UNDEFINED;
59
+ return JS_EXCEPTION;
130
60
  }
131
- JSValue ret = JS_Call(ctx, wrapper, JS_UNDEFINED, 1, &global_obj);
132
- JS_FreeValue(ctx, wrapper);
61
+ JSValue ret = JS_EvalFunction(ctx, JS_DupValue(ctx, JS_MKPTR(JS_TAG_MODULE, m)));
133
62
  if (JS_IsException(ret)) {
134
- JSValue exc = JS_GetException(ctx);
135
- JS_FreeValue(ctx, exc);
136
63
  JS_FreeValue(ctx, global_obj);
137
- return JS_UNDEFINED;
64
+ return JS_EXCEPTION;
65
+ }
66
+ if (JS_PromiseState(ctx, ret) == JS_PROMISE_REJECTED) {
67
+ JSValue err = JS_PromiseResult(ctx, ret);
68
+ JS_FreeValue(ctx, ret);
69
+ JS_FreeValue(ctx, global_obj);
70
+ return JS_Throw(ctx, err);
138
71
  }
139
72
  JS_FreeValue(ctx, ret);
140
73
 
@@ -145,7 +78,7 @@ static JSValue mik__abort_lazy_get(JSContext* ctx, JSValue this_val, int magic)
145
78
  }
146
79
 
147
80
  void mik__abort_init(JSContext* ctx, JSValue global_obj) {
148
- /* Install lazy getters instead of eagerly evaluating the JS.
81
+ /* Install lazy getters instead of eagerly evaluating the module.
149
82
  * Each getter shares the same C function, distinguished by magic. */
150
83
  for (int i = 0; i < ABORT_GLOBAL_COUNT; i++) {
151
84
  JSAtom prop = JS_NewAtom(ctx, abort_global_names[i]);
@@ -236,31 +236,32 @@ static JSValue mik__console_error_warn(JSContext* ctx, JSValue this_val, int arg
236
236
 
237
237
  /* ── Uncaught error reporting ─────────────────────────────────────── */
238
238
 
239
- /* Dedup: the rejection tracker can fire multiple times for the same
240
- * error (e.g. inner Promise.reject + async wrapper rejection). Skip
241
- * if the same object pointer was just reported. */
242
- static void* last_reported_ptr = nullptr;
243
- static int64_t last_reported_time = 0;
244
-
245
- void mik__report_uncaught_reset(void) {
246
- last_reported_ptr = nullptr;
247
- }
248
-
249
- void mik__report_uncaught(JSContext* ctx, JSValue exc, bool in_promise) {
239
+ /* Dedup across report paths: one error can reach this function more than
240
+ * once for a single failure. A failed dynamic import leaves two never-handled
241
+ * promises with the SAME reason (QuickJS runs a module body as an async
242
+ * function and reads its rejected result by value, plus the rejection
243
+ * propagated up the await chain), and at the REPL the throw path reports an
244
+ * error the deferred flush then sees again. Mark the error object the first
245
+ * time it is reported and skip it afterwards. Keying on object identity is
246
+ * exact and time-independent; a recycled address belongs to a fresh object
247
+ * with no marker, so distinct errors are never suppressed. Primitive
248
+ * rejections (e.g. a null OOM rejection) can't hold a marker and are always
249
+ * reported. Returns true if it reported, false if it was a duplicate. */
250
+ bool mik__report_uncaught(JSContext* ctx, JSValue exc, bool in_promise) {
250
251
  if (JS_IsObject(exc)) {
251
- void* ptr = JS_VALUE_GET_PTR(exc);
252
- int64_t now = MIK_GetPlatform()->get_boot_us();
253
- /* Deduplicate only within 1ms the rejection tracker can fire
254
- * twice for the same error (inner + async wrapper) essentially
255
- * instantly, but a new error 1s later at a recycled address
256
- * must not be suppressed. */
257
- if (ptr == last_reported_ptr && (now - last_reported_time) < 1000) {
258
- return;
252
+ JSAtom marker = JS_NewAtom(ctx, "\xff" "mik_reported");
253
+ if (marker != JS_ATOM_NULL) {
254
+ if (JS_HasProperty(ctx, exc, marker) > 0) {
255
+ JS_FreeAtom(ctx, marker);
256
+ return false;
257
+ }
258
+ /* Non-enumerable, non-writable, non-configurable so it stays
259
+ * invisible to user code. Best-effort: JS_* calls return error
260
+ * codes (no C++ throw), so under OOM we just report without
261
+ * dedup rather than abort. */
262
+ JS_DefinePropertyValue(ctx, exc, marker, JS_TRUE, 0);
263
+ JS_FreeAtom(ctx, marker);
259
264
  }
260
- last_reported_ptr = ptr;
261
- last_reported_time = now;
262
- } else {
263
- last_reported_ptr = nullptr;
264
265
  }
265
266
 
266
267
  /* Fixed-size stack buffer so this function never allocates from the
@@ -360,11 +361,12 @@ void mik__report_uncaught(JSContext* ctx, JSValue exc, bool in_promise) {
360
361
 
361
362
  if (mik__repl_is_protocol_mode()) {
362
363
  mik__repl_proto_send_output(MIK_MSG_ERROR, buf, pos);
363
- return;
364
+ return true;
364
365
  }
365
366
 
366
367
  append_cstr("\r\n");
367
368
  MIK_GetPlatform()->stderr_write(buf, pos);
369
+ return true;
368
370
  }
369
371
 
370
372
  /* ── Test emit ────────────────────────────────────────────────────── */
@@ -805,7 +805,7 @@ std::string mik_inspect(JSContext* ctx, JSValue value, int depth, bool colors, b
805
805
  return inspect_value(ctx, value, opts, depth);
806
806
  }
807
807
 
808
- /* ── JS-callable inspect function (for mikrojs/inspect module) ───── */
808
+ /* ── JS-callable inspect function (for mikro/inspect module) ───── */
809
809
 
810
810
  static JSValue mik__inspect_js(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
811
811
  if (argc < 1) return JS_NewString(ctx, "undefined");
@@ -836,14 +836,14 @@ static JSValue mik__inspect_js(JSContext* ctx, JSValue this_val, int argc, JSVal
836
836
  return JS_NewString(ctx, result.c_str());
837
837
  }
838
838
 
839
- /* Module init for 'mikrojs/inspect' native C module */
839
+ /* Module init for 'mikro/inspect' native C module */
840
840
  int mik__inspect_module_init(JSContext* ctx, JSModuleDef* m) {
841
841
  return JS_SetModuleExport(ctx, m, "inspect",
842
842
  JS_NewCFunction(ctx, mik__inspect_js, "inspect", 1));
843
843
  }
844
844
 
845
845
  void mik__inspect_register(JSContext* ctx) {
846
- JSModuleDef* m = JS_NewCModule(ctx, "mikrojs/inspect", mik__inspect_module_init);
846
+ JSModuleDef* m = JS_NewCModule(ctx, "mikro/inspect", mik__inspect_module_init);
847
847
  if (m) {
848
848
  JS_AddModuleExport(ctx, m, "inspect");
849
849
  }
package/src/mik_repl.cpp CHANGED
@@ -263,6 +263,10 @@ static JSValue repl_eval_and_pump(JSContext* ctx, const char* code, size_t len)
263
263
  return pr;
264
264
  }
265
265
  if (state == JS_PROMISE_REJECTED) {
266
+ /* This promise's rejection is about to be reported via the throw
267
+ * path below; drop it from the deferred unhandled-rejection queue
268
+ * so the end-of-turn flush doesn't report it a second time. */
269
+ mik__forget_rejection(ctx, result);
266
270
  JSValue reason = JS_PromiseResult(ctx, result);
267
271
  JS_FreeValue(ctx, result);
268
272
  JS_Throw(ctx, reason);
package/src/mik_sys.cpp CHANGED
@@ -260,7 +260,17 @@ void mik__sys_api_init(JSContext* ctx, JSValue ns) {
260
260
  JS_SetPropertyStr(ctx, ns, "board", mik__sys_board(ctx));
261
261
  JS_SetPropertyStr(ctx, ns, "firmware", mik__sys_firmware(ctx));
262
262
 
263
- const char* device_id = MIK_GetPlatform()->get_device_id();
263
+ /* Guard the platform function POINTERS, not just their results: a
264
+ * platform that omits an optional hook leaves a NULL pointer, and
265
+ * calling it is a segfault with no diagnostics (the sim was down for
266
+ * weeks because platform_node lacked get_reset_reason). */
267
+ const MIKPlatform* platform = MIK_GetPlatform();
268
+ const char* device_id = platform->get_device_id ? platform->get_device_id() : NULL;
264
269
  JS_SetPropertyStr(ctx, ns, "deviceId",
265
270
  device_id ? JS_NewString(ctx, device_id) : JS_UNDEFINED);
271
+
272
+ const char* reset_reason =
273
+ platform->get_reset_reason ? platform->get_reset_reason() : NULL;
274
+ JS_SetPropertyStr(ctx, ns, "resetReason",
275
+ JS_NewString(ctx, reset_reason ? reset_reason : "unknown"));
266
276
  }
package/src/mikrojs.cpp CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  #include <quickjs.h>
4
4
 
5
+ #include <atomic>
5
6
  #include <cerrno>
6
7
  #include <cstdio>
7
8
  #include <fcntl.h>
@@ -91,7 +92,7 @@ static void mik__add_exports(JSContext* ctx, JSModuleDef* m, const char* const*
91
92
  static const char* const sys_exports[] = {
92
93
  "evalScript", "memoryUsage", "jsMemoryUsage", "gc", "setTime",
93
94
  "uptime", "restart", "version", "board", "firmware",
94
- "deviceId", "activeTimers", "unloadNamespace", "isUnloadableNamespace"};
95
+ "deviceId", "resetReason", "activeTimers", "unloadNamespace", "isUnloadableNamespace"};
95
96
 
96
97
  static int mik__sys_module_init(JSContext* ctx, JSModuleDef* m) {
97
98
  JSValue ns = JS_NewObjectProto(ctx, JS_NULL);
@@ -111,21 +112,19 @@ static int mik__stdio_module_init(JSContext* ctx, JSModuleDef* m) {
111
112
  return 0;
112
113
  }
113
114
 
114
- static JSValue mik__dispatch_event(JSContext* ctx, JSValue* event) {
115
- MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
116
- CHECK_NOT_NULL(mik_rt);
117
-
118
- if (mik_rt->freeing) {
119
- return JS_UNDEFINED;
120
- }
121
-
122
- JSValue global_obj = JS_GetGlobalObject(ctx);
123
- JSValue ret = JS_Call(ctx, mik_rt->builtins.dispatch_event_func, global_obj, 1, event);
124
- JS_FreeValue(ctx, global_obj);
125
-
126
- return ret;
127
- }
128
-
115
+ /* Host promise-rejection tracker. Rather than report eagerly the instant a
116
+ * promise rejects, defer the decision to the end-of-turn flush
117
+ * (mik__flush_unhandled_rejections): a promise that rejects without a
118
+ * handler is queued, and a promise that later gets a handler is removed
119
+ * from the queue. This mirrors the HTML/WinterCG unhandledrejection
120
+ * algorithm. It matters because some promises are born rejected before a
121
+ * handler can be attached. The clearest example is a module's evaluation
122
+ * promise, which the dynamic-import loader rejects (when the module body
123
+ * throws) and only then attaches its .then to. Reporting eagerly surfaced
124
+ * that transient rejection as a spurious second "Uncaught (in promise)".
125
+ *
126
+ * This is the same add-on-reject / remove-on-handle / report-after-drain
127
+ * pattern quickjs-libc's js_std_promise_rejection_tracker uses. */
129
128
  static void mik__promise_rejection_tracker(JSContext* ctx, JSValue promise, JSValue reason,
130
129
  bool is_handled, void* opaque) {
131
130
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
@@ -135,68 +134,109 @@ static void mik__promise_rejection_tracker(JSContext* ctx, JSValue promise, JSVa
135
134
  return;
136
135
  }
137
136
 
138
- if (!is_handled) {
139
- /* If the runtime hasn't loaded the event infrastructure yet,
140
- * report and bail. */
141
- if (JS_IsUndefined(mik_rt->builtins.promise_event_ctor) ||
142
- JS_IsUndefined(mik_rt->builtins.dispatch_event_func)) {
143
- /* During REPL eval, sync errors are wrapped by the async eval
144
- * wrapper and appear as promise rejections don't label them
145
- * "(in promise)". Outside eval (e.g. timer callbacks), they
146
- * are genuine unhandled promise rejections. */
147
- bool in_promise = !mik__repl_is_evaluating();
148
- mik__report_uncaught(ctx, reason, in_promise);
149
- /* Notify the error handler (e.g. host bridge) directly.
150
- * Don't JS_Throw here — we're inside a QuickJS callback
151
- * during promise resolution; throwing would corrupt engine
152
- * state and cause mik__execute_jobs to dump the error a
153
- * second time. MIK_Stop gates on whether we're inside an
154
- * interactive REPL eval (skipping both the stop request and
155
- * the restart) so a typo at the prompt doesn't reboot the
156
- * device. */
157
- if (mik_rt->error_handler_fn) {
158
- mik_rt->error_handler_fn(ctx, reason, mik_rt->error_handler_opaque);
137
+ auto& pending = mik_rt->pending_rejections;
138
+ void* key = JS_VALUE_GET_PTR(promise);
139
+
140
+ if (is_handled) {
141
+ /* A handler was attached to a previously-rejected promise: cancel its
142
+ * pending report. */
143
+ for (size_t i = 0; i < pending.size(); i++) {
144
+ if (JS_VALUE_GET_PTR(pending[i].promise) == key) {
145
+ JS_FreeValue(ctx, pending[i].promise);
146
+ JS_FreeValue(ctx, pending[i].reason);
147
+ pending.erase(pending.begin() + i);
148
+ return;
159
149
  }
160
- MIK_Stop(mik_rt);
161
- return;
162
150
  }
151
+ return;
152
+ }
153
+
154
+ /* Rejected with no handler (yet): queue it. Hold references to both the
155
+ * promise and the reason so they stay alive until the end-of-turn check. */
156
+ pending.push_back({JS_DupValue(ctx, promise), JS_DupValue(ctx, reason)});
157
+ }
163
158
 
164
- JSValue event_name = JS_NewString(ctx, "unhandledrejection");
165
- JSValue args[3];
166
- args[0] = event_name;
167
- args[1] = promise;
168
- args[2] = reason;
169
-
170
- JSValue event =
171
- JS_CallConstructor(ctx, mik_rt->builtins.promise_event_ctor, countof(args), args);
172
- if (JS_IsException(event)) {
173
- JS_FreeValue(ctx, event_name);
174
- mik_dump_error(ctx);
159
+ /* Drop a promise from the pending-rejection queue without reporting it. The
160
+ * REPL eval pump calls this for the result promise it polls and reports via
161
+ * the throw path, so the end-of-turn flush doesn't report it a second time. */
162
+ void mik__forget_rejection(JSContext* ctx, JSValue promise) {
163
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
164
+ if (!mik_rt) {
165
+ return;
166
+ }
167
+ auto& pending = mik_rt->pending_rejections;
168
+ void* key = JS_VALUE_GET_PTR(promise);
169
+ for (size_t i = 0; i < pending.size(); i++) {
170
+ if (JS_VALUE_GET_PTR(pending[i].promise) == key) {
171
+ JS_FreeValue(ctx, pending[i].promise);
172
+ JS_FreeValue(ctx, pending[i].reason);
173
+ pending.erase(pending.begin() + i);
175
174
  return;
176
175
  }
177
- JSValue ret = mik__dispatch_event(ctx, &event);
176
+ }
177
+ }
178
178
 
179
- JS_FreeValue(ctx, event);
180
- JS_FreeValue(ctx, event_name);
179
+ /* End-of-turn unhandled-rejection check. Any promise still queued after a
180
+ * microtask drain rejected and never got a handler: report it, notify the
181
+ * host error handler, and halt. */
182
+ void mik__flush_unhandled_rejections(JSContext* ctx) {
183
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
184
+ if (!mik_rt || mik_rt->freeing || mik_rt->pending_rejections.empty()) {
185
+ return;
186
+ }
187
+ /* During interactive REPL eval, the eval pump polls its result promise
188
+ * and reports rejections via the throw path; leave the queue alone until
189
+ * the eval finishes so we don't fight that model. */
190
+ if (mik__repl_is_evaluating()) {
191
+ return;
192
+ }
181
193
 
182
- if (JS_IsException(ret)) {
183
- mik_dump_error(ctx);
184
- goto fail;
185
- } else {
186
- if (JS_ToBool(ctx, ret)) {
187
- // The event wasn't cancelled, maybe abort.
188
- fail:;
189
- MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
190
- CHECK_NOT_NULL(mik_rt);
191
- mik__report_uncaught(ctx, reason, true);
192
- if (mik_rt->error_handler_fn) {
193
- mik_rt->error_handler_fn(ctx, reason, mik_rt->error_handler_opaque);
194
- }
195
- MIK_Stop(mik_rt);
194
+ /* Move the queue aside before reporting. Reporting doesn't enqueue JS
195
+ * jobs, but this keeps the runtime's vector in a clean state regardless. */
196
+ std::vector<MIKRejectedPromise> rejected;
197
+ rejected.swap(mik_rt->pending_rejections);
198
+
199
+ /* One failed dynamic import leaves two never-handled promises with the
200
+ * SAME reason: the module's internal sync-evaluation promise (QuickJS runs
201
+ * a module body as an async function and reads its rejected result by
202
+ * value, never attaching a handler) and the promise the error propagates
203
+ * to up the await chain. mik__report_uncaught dedups by reason-object
204
+ * identity, so the second is a no-op; only act on a fresh report so the
205
+ * host bridge and the halt don't fire twice. (quickjs-libc reports every
206
+ * entry; we collapse same-error duplicates.) */
207
+ for (const MIKRejectedPromise& rp : rejected) {
208
+ if (mik__report_uncaught(ctx, rp.reason, true)) {
209
+ /* Notify the error handler (e.g. host bridge) directly. Don't
210
+ * JS_Throw here: we're between jobs, and throwing would be picked
211
+ * up as a spurious pending exception. */
212
+ if (mik_rt->error_handler_fn) {
213
+ mik_rt->error_handler_fn(ctx, rp.reason, mik_rt->error_handler_opaque);
196
214
  }
215
+ MIK_Stop(mik_rt);
197
216
  }
217
+ JS_FreeValue(ctx, rp.promise);
218
+ JS_FreeValue(ctx, rp.reason);
219
+ }
220
+ }
198
221
 
199
- JS_FreeValue(ctx, ret);
222
+ /* QuickJS only runs the cycle collector automatically once malloc_size
223
+ * crosses the runtime's GC threshold (256 KB by default), and js_malloc_rt
224
+ * fails against the memory limit without attempting a GC first. On devices
225
+ * where mem_limit is below the default threshold the collector would never
226
+ * fire before allocations start failing, so cyclic garbage (promise chains,
227
+ * closures) accumulates straight into OOM with collectable memory still
228
+ * alive. Cap the threshold below the limit so collection happens first.
229
+ * QuickJS raises the threshold to 1.5x the live size after every GC pass,
230
+ * which can push it back above the limit, so MIK_Loop re-applies the cap
231
+ * each turn. */
232
+ static void mik__clamp_gc_threshold(MIKRuntime* mik_rt) {
233
+ if (mik_rt->options.mem_limit <= 0) {
234
+ return;
235
+ }
236
+ size_t limit = (size_t)mik_rt->options.mem_limit;
237
+ size_t cap = limit - limit / 8;
238
+ if (JS_GetGCThreshold(mik_rt->rt) > cap) {
239
+ JS_SetGCThreshold(mik_rt->rt, cap);
200
240
  }
201
241
  }
202
242
 
@@ -289,12 +329,25 @@ MIKRuntime* MIK_NewRuntimeInternal(MIKRunOptions* options) {
289
329
  * exhaust the heap. Overridden via MIK_SetFSReadMax. */
290
330
  mik_rt->fs_read_max = 65536;
291
331
 
332
+ /* Reserve the unhandled-rejection queue up-front so the rejection tracker
333
+ * never has to grow a std::vector on the rejection path. That path fires
334
+ * for a QuickJS null-OOM rejection, and a reallocation there could throw
335
+ * std::bad_alloc — which, with CONFIG_COMPILER_CXX_EXCEPTIONS=n on ESP32,
336
+ * aborts and masks the very OOM we're trying to report. Four slots covers
337
+ * any realistic per-turn rejection count; the runtime halts on the first
338
+ * one anyway. */
339
+ mik_rt->pending_rejections.reserve(4);
340
+
292
341
  /* Check for duplicate native module registrations (global registry) */
293
342
  mik__check_module_collisions();
294
343
 
295
344
  /* Set memory limit */
296
345
  JS_SetMemoryLimit(rt, options->mem_limit);
297
346
 
347
+ /* Make sure the cycle collector fires before the memory limit does
348
+ * (see mik__clamp_gc_threshold). */
349
+ mik__clamp_gc_threshold(mik_rt);
350
+
298
351
  /* Set stack size */
299
352
  JS_SetMaxStackSize(rt, options->stack_size);
300
353
  /* loader for ES modules */
@@ -337,11 +390,6 @@ MIKRuntime* MIK_NewRuntimeInternal(MIKRunOptions* options) {
337
390
  mik__text_encoding_init(ctx, global_obj);
338
391
  mik__abort_init(ctx, global_obj);
339
392
 
340
- /* Load some builtin references for easy access */
341
- mik_rt->builtins.dispatch_event_func = JS_GetPropertyStr(ctx, global_obj, "dispatchEvent");
342
- mik_rt->builtins.promise_event_ctor =
343
- JS_GetPropertyStr(mik_rt->ctx, global_obj, "PromiseRejectionEvent");
344
-
345
393
  /* Timers */
346
394
  mik_rt->timers = MIK_NewTimerRegistry();
347
395
  mik__timers_init(ctx, global_obj);
@@ -369,6 +417,13 @@ void MIK_FreeRuntime(MIKRuntime* mik_rt) {
369
417
  mik_rt->stdin_state.on_data = JS_UNDEFINED;
370
418
  }
371
419
 
420
+ /* Release any still-pending unhandled-rejection entries. */
421
+ for (const MIKRejectedPromise& rp : mik_rt->pending_rejections) {
422
+ JS_FreeValue(mik_rt->ctx, rp.promise);
423
+ JS_FreeValue(mik_rt->ctx, rp.reason);
424
+ }
425
+ mik_rt->pending_rejections.clear();
426
+
372
427
  /* Destroy registered loop consumers */
373
428
  for (const auto& consumer : mik_rt->loop_consumers) {
374
429
  if (consumer.destroy_fn) {
@@ -384,10 +439,6 @@ void MIK_FreeRuntime(MIKRuntime* mik_rt) {
384
439
  /* Destroy the JS engine. */
385
440
  JS_FreeValue(mik_rt->ctx, mik_rt->env_obj);
386
441
  mik_rt->env_obj = JS_UNDEFINED;
387
- JS_FreeValue(mik_rt->ctx, mik_rt->builtins.dispatch_event_func);
388
- mik_rt->builtins.dispatch_event_func = JS_UNDEFINED;
389
- JS_FreeValue(mik_rt->ctx, mik_rt->builtins.promise_event_ctor);
390
- mik_rt->builtins.promise_event_ctor = JS_UNDEFINED;
391
442
  JS_FreeValue(mik_rt->ctx, mik_rt->result_ok_void_singleton);
392
443
  mik_rt->result_ok_void_singleton = JS_UNDEFINED;
393
444
  JS_FreeValue(mik_rt->ctx, mik_rt->result_proto);
@@ -510,9 +561,43 @@ static void mik__check_module_collisions(void) {
510
561
  }
511
562
  }
512
563
 
564
+ /* A chain of already-settled promises drains as one uninterrupted storm of
565
+ * jobs, and the test supervisor strings whole files of such chains together
566
+ * with runtime recycles in between. On ESP32 the main task runs above the
567
+ * idle task, so a multi-second stretch like that starves idle: the task
568
+ * watchdog (5 s, watching IDLE0) prints a register dump, and FreeRTOS
569
+ * housekeeping (freeing TCBs of exited tasks, e.g. per-request HTTP tasks)
570
+ * stalls. Force one platform yield per second of continuous job execution
571
+ * so idle always gets a slice. The timestamp is global on purpose: the
572
+ * starvation accumulates across MIK_Loop calls and across runtime recycles.
573
+ * Costs at most one tick (~10 ms) per second of busy work. */
574
+ #define MIK__YIELD_INTERVAL_US (1000 * 1000)
575
+
576
+ /* Atomic because the Node addon can pump two runtimes' loops from
577
+ * different worker threads; relaxed ordering is enough — a missed or
578
+ * extra yield is harmless, a torn 64-bit write is not. */
579
+ static std::atomic<int64_t> mik__last_yield_us{0};
580
+
581
+ static void mik__maybe_yield(void) {
582
+ const MIKPlatform* platform = MIK_GetPlatform();
583
+ int64_t now = platform->get_boot_us();
584
+ int64_t last = mik__last_yield_us.load(std::memory_order_relaxed);
585
+ /* now < last happens when the boot clock resets (deep sleep) or a test
586
+ * swaps in a platform with a different clock; restart the interval. */
587
+ if (last == 0 || now < last) {
588
+ mik__last_yield_us.store(now, std::memory_order_relaxed);
589
+ return;
590
+ }
591
+ if (now - last >= MIK__YIELD_INTERVAL_US) {
592
+ platform->yield();
593
+ mik__last_yield_us.store(platform->get_boot_us(), std::memory_order_relaxed);
594
+ }
595
+ }
596
+
513
597
  void mik__execute_jobs(JSContext* ctx) {
514
598
  JSContext* ctx1;
515
599
  int err;
600
+ bool ran_any = false;
516
601
 
517
602
  /* execute the pending jobs */
518
603
  for (;;) {
@@ -529,7 +614,31 @@ void mik__execute_jobs(JSContext* ctx) {
529
614
  }
530
615
  break;
531
616
  }
617
+ ran_any = true;
618
+ /* A GC pass inside this job raises the threshold to 1.5x live size,
619
+ * which lands above mem_limit whenever live >= ~2/3 of the limit —
620
+ * silently disabling cycle GC for the rest of the storm. Re-clamp
621
+ * between jobs so collection keeps firing; the check is a field
622
+ * read + compare when no GC ran. */
623
+ MIKRuntime* job_rt = MIK_GetRuntime(ctx1);
624
+ if (job_rt) {
625
+ mik__clamp_gc_threshold(job_rt);
626
+ }
627
+ mik__maybe_yield();
532
628
  }
629
+
630
+ /* The yield interval measures continuous BUSY work. An empty drain
631
+ * means the loop is idling (the idle task is getting time between
632
+ * turns), so re-arm the interval — otherwise the first job after a
633
+ * quiet period pays a spurious ~10 ms yield for wall-clock time spent
634
+ * doing nothing. */
635
+ if (!ran_any) {
636
+ mik__last_yield_us.store(MIK_GetPlatform()->get_boot_us(), std::memory_order_relaxed);
637
+ }
638
+
639
+ /* Microtask checkpoint: every reject/handle transition for this turn has
640
+ * now run, so whatever is still queued is a genuine unhandled rejection. */
641
+ mik__flush_unhandled_rejections(ctx);
533
642
  }
534
643
 
535
644
  /* main loop which calls the user JS callbacks */
@@ -576,7 +685,18 @@ int MIK_Loop(MIKRuntime* mik_rt) {
576
685
  }
577
686
 
578
687
  mik__execute_jobs(mik_rt->ctx);
579
- return 0;
688
+
689
+ /* Backstop for GC passes outside the job drain (timer callbacks, loop
690
+ * consumers, top-level eval): a pass there may have raised the
691
+ * threshold above the memory limit; pull it back so the next turn's
692
+ * collection still fires before allocations fail. Within the job
693
+ * drain, mik__execute_jobs re-clamps after every job. */
694
+ mik__clamp_gc_threshold(mik_rt);
695
+
696
+ /* The end-of-turn unhandled-rejection flush inside mik__execute_jobs may
697
+ * have requested a stop; surface it this iteration rather than making the
698
+ * caller loop once more to notice. */
699
+ return mik_rt->stop_requested ? 1 : 0;
580
700
  }
581
701
 
582
702
  void MIK_SetConfig(MIKRuntime* mik_rt, const MIKConfig* config) {
@@ -961,6 +1081,13 @@ JSValue MIK_EvalModule(JSContext* ctx, const char* filename, bool is_main) {
961
1081
  }
962
1082
 
963
1083
  int MIK_RunEntry(MIKRuntime* mik_rt, const char* entry) {
1084
+ return MIK_RunEntryErr(mik_rt, entry, NULL, 0);
1085
+ }
1086
+
1087
+ int MIK_RunEntryErr(MIKRuntime* mik_rt, const char* entry, char* err_buf, size_t err_buf_size) {
1088
+ if (err_buf && err_buf_size > 0) {
1089
+ err_buf[0] = '\0';
1090
+ }
964
1091
  if (!mik_rt || !entry || entry[0] == '\0') {
965
1092
  return -EINVAL;
966
1093
  }
@@ -987,6 +1114,7 @@ int MIK_RunEntry(MIKRuntime* mik_rt, const char* entry) {
987
1114
 
988
1115
  JSValue result = MIK_EvalModule(ctx, entry, true);
989
1116
  bool failed = JS_IsException(result);
1117
+ bool rejected = false;
990
1118
  /* Modules with top-level await return a Promise. A module body that
991
1119
  * throws synchronously rejects that promise before JS_EvalFunction
992
1120
  * returns, but the value itself is still an Object (rejected Promise),
@@ -998,7 +1126,25 @@ int MIK_RunEntry(MIKRuntime* mik_rt, const char* entry) {
998
1126
  JSPromiseStateEnum state = JS_PromiseState(ctx, result);
999
1127
  if (state == JS_PROMISE_REJECTED) {
1000
1128
  failed = true;
1129
+ rejected = true;
1130
+ }
1131
+ }
1132
+ if (failed && err_buf && err_buf_size > 0) {
1133
+ /* Capture the failure's string form for the caller. Without an
1134
+ * err_buf the pending exception is intentionally left on ctx,
1135
+ * matching MIK_RunEntry's historical behavior. */
1136
+ JSValue exc = rejected ? JS_PromiseResult(ctx, result) : JS_GetException(ctx);
1137
+ const char* msg = JS_ToCString(ctx, exc);
1138
+ if (msg) {
1139
+ snprintf(err_buf, err_buf_size, "%s", msg);
1140
+ JS_FreeCString(ctx, msg);
1141
+ } else {
1142
+ /* Stringifying the exception itself threw (e.g. OOM) — drop
1143
+ * the secondary exception so it can't leak into later evals. */
1144
+ JSValue stray = JS_GetException(ctx);
1145
+ JS_FreeValue(ctx, stray);
1001
1146
  }
1147
+ JS_FreeValue(ctx, exc);
1002
1148
  }
1003
1149
  JS_FreeValue(ctx, result);
1004
1150
  return failed ? -EFAULT : 0;
package/src/modules.cpp CHANGED
@@ -245,11 +245,11 @@ static JSModuleDef* mik_module_loader_inner(JSContext* ctx, const char* module_n
245
245
  /* mik__load_builtin returns NULL either because the module isn't in
246
246
  * the table, or because deserialization failed (e.g. stack overflow
247
247
  * from deeply nested .bjs loading). If deserialization failed, the
248
- * real exception is already on ctx — don't replace it. */
249
- JSValue pending = JS_GetException(ctx);
250
- if (!JS_IsNull(pending)) {
251
- JS_Throw(ctx, pending);
252
- } else {
248
+ * real exception is already on ctx — don't replace it. Probe with
249
+ * JS_HasException, NOT JS_GetException + JS_IsNull: quickjs-ng
250
+ * returns JS_UNINITIALIZED from an empty exception slot, and
251
+ * re-throwing that surfaced as a bare "[uninitialized]" error. */
252
+ if (!JS_HasException(ctx)) {
253
253
  JS_ThrowReferenceError(ctx, "Builtin module '%s' is not available in this build",
254
254
  module_name);
255
255
  }
@@ -285,13 +285,8 @@ JSModuleDef* mik_module_loader(JSContext* ctx, const char* module_name, void* op
285
285
  JSModuleDef* result = mik_module_loader_inner(ctx, module_name, opaque);
286
286
  if (debug) {
287
287
  if (result == nullptr) {
288
- JSValue exc = JS_GetException(ctx);
289
- bool has_exc = !JS_IsNull(exc) && !JS_IsUndefined(exc);
290
288
  fprintf(stderr, "[mik-modules] FAIL: %s (exception=%s)\n", module_name,
291
- has_exc ? "set" : "NULL");
292
- /* Re-throw so the caller still sees the exception. */
293
- if (has_exc) JS_Throw(ctx, exc);
294
- else JS_FreeValue(ctx, exc);
289
+ JS_HasException(ctx) ? "set" : "NULL");
295
290
  } else {
296
291
  fprintf(stderr, "[mik-modules] ok: %s\n", module_name);
297
292
  }
@@ -319,12 +314,8 @@ JSModuleDef* mik_module_loader(JSContext* ctx, const char* module_name, void* op
319
314
  JSModuleDef* m = mik_module_loader_inner(ctx, module_name, opaque);
320
315
  if (debug) {
321
316
  if (m == nullptr) {
322
- JSValue exc = JS_GetException(ctx);
323
- bool has_exc = !JS_IsNull(exc) && !JS_IsUndefined(exc);
324
317
  fprintf(stderr, "[mik-modules] FAIL: %s (exception=%s)\n", module_name,
325
- has_exc ? "set" : "NULL");
326
- if (has_exc) JS_Throw(ctx, exc);
327
- else JS_FreeValue(ctx, exc);
318
+ JS_HasException(ctx) ? "set" : "NULL");
328
319
  } else {
329
320
  fprintf(stderr, "[mik-modules] ok: %s\n", module_name);
330
321
  }
@@ -116,6 +116,10 @@ static const char* posix_get_device_id(void) {
116
116
  return id;
117
117
  }
118
118
 
119
+ static const char* posix_get_reset_reason(void) {
120
+ return "unknown"; /* Desktop processes have no chip reset concept */
121
+ }
122
+
119
123
  static void posix_log(int level, const char* tag, const char* fmt, ...) {
120
124
  if (level < MIK_LOG_ERROR || level > MIK_LOG_VERBOSE) return;
121
125
  fprintf(stderr, "[%s] %s: ", mik_log_level_name(level), tag);
@@ -147,6 +151,7 @@ static const MIKPlatform posix_platform = {
147
151
  .stderr_write = posix_stderr_write,
148
152
  .stdin_read = posix_stdin_read,
149
153
  .get_device_id = posix_get_device_id,
154
+ .get_reset_reason = posix_get_reset_reason,
150
155
  };
151
156
 
152
157
  static const MIKPlatform* current_platform = &posix_platform;