@mikrojs/native 0.19.0 → 0.20.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.
@@ -29,40 +29,62 @@ interface Suite {
29
29
 
30
30
  const suites: Suite[] = []
31
31
  let currentSuite: Suite | null = null
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. */
32
+ /** JS heap (bytes) the file still holds above baseline, summed over suites.
33
+ * Retention accumulates, so a per-suite figure is added rather than
34
+ * replacing the last one: a leak in suite 1 is still a leak once suite 5
35
+ * has run. */
36
+ let heapRetained = 0
37
+ /** Baseline the running suite is measured against. Set to the previous
38
+ * suite's closing heap, or recaptured after a beforeAll so that suite's
39
+ * warmup is excluded. */
40
+ let suiteBaseline = 0
41
+ /** Free system heap (bytes) at the start of the run, or 0 on the host (no
42
+ * system heap). Never recaptured: sysUsed is a peak, and warmup (module
43
+ * loads, TLS, wifi) is memory the file genuinely needed at once, so
44
+ * excluding it would understate how close the run came to OOM. Retention is
45
+ * the figure that wants warmup excluded, and it has its own baseline. */
38
46
  let sysFreeStart = 0
39
47
  /** 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
48
+ * host. The file's closest sampled approach to OOM. The module re-inits
41
49
  * for each test file (fresh runtime per file), so this is a true per-file
42
50
  * figure, not a process-lifetime watermark shared across files. */
43
51
  let sysFreeFloor = 0
52
+ /** Free system heap at the running suite's start, for its own peak. */
53
+ let suiteFreeStart = 0
54
+ /** Lowest free system heap sampled during the running suite. */
55
+ let suiteFreeFloor = 0
44
56
 
45
57
  /**
46
- * Recapture the heap baseline. Called by the harness after each suite's
58
+ * Recapture the suite baseline. Called by the harness after each suite's
47
59
  * beforeAll resolves so warmup allocations (module loads, fetch/TLS
48
60
  * lazy-init, wifi connection) don't count toward heapDelta. A microtask
49
61
  * yield before the gc lets the beforeAll async frame's locals become
50
62
  * collectible — otherwise the baseline would be inflated by vars that
51
63
  * were still pinned by the suspended closure when beforeAll resolved,
52
- * and heapAfter would come in lower than baseline (negative delta).
64
+ * and the suite would close below its baseline (negative delta).
53
65
  */
54
- async function captureHeapBaseline(): Promise<void> {
66
+ async function captureSuiteBaseline(): Promise<void> {
55
67
  await Promise.resolve()
68
+ gc()
69
+ const {heapUsed} = memoryUsage()
70
+ suiteBaseline = heapUsed
71
+ }
72
+
73
+ /**
74
+ * Close out the running suite: fold its retention into the file total and
75
+ * start the next suite from where this one ended. Returns the suite's own
76
+ * figures for the suite_end event, which is what a reader needs to find the
77
+ * suite behind a file-level regression.
78
+ */
79
+ function closeSuite(): {retained: number; sysUsed: number} {
56
80
  gc()
57
81
  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
- }
82
+ const retained = heapUsed - suiteBaseline
83
+ heapRetained += retained
84
+ suiteBaseline = heapUsed
85
+ if (systemFree > 0 && systemFree < suiteFreeFloor) suiteFreeFloor = systemFree
86
+ const sysUsed = suiteFreeStart > suiteFreeFloor ? suiteFreeStart - suiteFreeFloor : 0
87
+ return {retained, sysUsed}
66
88
  }
67
89
 
68
90
  function newSuite(name: string, flags: {skip?: boolean; only?: boolean; todo?: boolean}): Suite {
@@ -372,7 +394,7 @@ type TestEvent =
372
394
  | {e: 2; s: string; t: string; d: number}
373
395
  | {e: 3; s: string; t: string; d: number; m: string}
374
396
  | {e: 4; s: string; t: string}
375
- | {e: 5; s: string}
397
+ | {e: 5; s: string; hr?: number; su?: number}
376
398
  | {
377
399
  e: 6
378
400
  p: number
@@ -382,6 +404,7 @@ type TestEvent =
382
404
  d: number
383
405
  hb?: number
384
406
  ha?: number
407
+ hr?: number
385
408
  su?: number
386
409
  sf?: number
387
410
  tb?: number
@@ -425,6 +448,7 @@ function emitHeap(): void {
425
448
  if (mem.systemFree > 0) {
426
449
  evt.f = mem.systemFree
427
450
  if (sysFreeFloor === 0 || mem.systemFree < sysFreeFloor) sysFreeFloor = mem.systemFree
451
+ if (suiteFreeFloor === 0 || mem.systemFree < suiteFreeFloor) suiteFreeFloor = mem.systemFree
428
452
  }
429
453
  if (mem.systemMinFree > 0) evt.mf = mem.systemMinFree
430
454
  emit(evt)
@@ -445,9 +469,10 @@ async function run(): Promise<void> {
445
469
  // folded into the baseline automatically.
446
470
  gc()
447
471
  // Destructure to primitives so the live memoryUsage() object isn't held
448
- // while heapBaseline is captured (it would otherwise be counted in it).
472
+ // while the baseline is captured (it would otherwise be counted in it).
449
473
  const {heapUsed: startHeap, systemFree: startFree} = memoryUsage()
450
- heapBaseline = startHeap
474
+ heapRetained = 0
475
+ suiteBaseline = startHeap
451
476
  if (startFree > 0) {
452
477
  sysFreeStart = startFree
453
478
  sysFreeFloor = startFree
@@ -459,7 +484,10 @@ async function run(): Promise<void> {
459
484
  const hasOnly = suites.some((s) => s.only || s.tests.some((t) => t.only))
460
485
 
461
486
  for (const suite of suites) {
487
+ // Open this suite's own peak window on the post-gc sample emitHeap takes.
488
+ suiteFreeFloor = 0
462
489
  emitHeap()
490
+ suiteFreeStart = suiteFreeFloor
463
491
  emit({e: 1, s: suite.name, n: suite.tests.length})
464
492
 
465
493
  if (suite.skip) {
@@ -527,11 +555,10 @@ async function run(): Promise<void> {
527
555
  continue
528
556
  }
529
557
  // Recapture the baseline now that beforeAll has fully resolved and
530
- // its closure frame is eligible for collection. Multi-suite files
531
- // let the last successful beforeAll set the baseline fine because
532
- // every earlier suite's test allocations have already been bounded
533
- // by the previous (stricter) baseline.
534
- await captureHeapBaseline()
558
+ // its closure frame is eligible for collection. Only this suite is
559
+ // measured against it: earlier suites were already closed out into
560
+ // heapRetained, so their allocations survive the recapture.
561
+ await captureSuiteBaseline()
535
562
  }
536
563
 
537
564
  for (const t of suite.tests) {
@@ -600,7 +627,10 @@ async function run(): Promise<void> {
600
627
  }
601
628
  }
602
629
 
603
- emit({e: 5, s: suite.name})
630
+ const closed = closeSuite()
631
+ const endEvt: TestEvent = {e: 5, s: suite.name, hr: closed.retained}
632
+ if (closed.sysUsed > 0) endEvt.su = closed.sysUsed
633
+ emit(endEvt)
604
634
  }
605
635
 
606
636
  gc()
@@ -610,14 +640,20 @@ async function run(): Promise<void> {
610
640
  if (endFree > 0 && (sysFreeFloor === 0 || endFree < sysFreeFloor)) {
611
641
  sysFreeFloor = endFree
612
642
  }
643
+ // Fold in whatever ran outside a suite's own accounting: a file with no
644
+ // suites at all, and the skip/todo bookkeeping between them.
645
+ heapRetained += heapAfter - suiteBaseline
613
646
  const timersAfter = activeTimers()
614
647
  const pendingAfter = pendingHttpCount()
615
648
 
616
649
  // Per-file system-heap figures. sysFreeFloor is the lowest free heap we
617
650
  // sampled (post-gc) this run; sysUsed is how far free heap fell from the
618
- // baseline to that low. They sum back to the baseline free, so "peak" and
619
- // "min free" read as one story. Samples are taken between tests, so a
620
- // transient peak inside a single test can dip below what sysFreeFloor saw.
651
+ // run's start to that low. They sum back to the starting free, so "peak"
652
+ // and "min free" read as one story. Measured from the start of the run and
653
+ // never rebaselined: unlike retention, a peak wants warmup counted, since
654
+ // memory a beforeAll takes is memory the file needed at once. Samples are
655
+ // taken between tests, so a transient peak inside a single test can dip
656
+ // below what sysFreeFloor saw.
621
657
  const sysUsed = sysFreeStart > sysFreeFloor ? sysFreeStart - sysFreeFloor : 0
622
658
 
623
659
  const doneEvt: TestEvent = {
@@ -627,8 +663,9 @@ async function run(): Promise<void> {
627
663
  k: skipped,
628
664
  o: todo,
629
665
  d: elapsedMs(startTime),
630
- hb: heapBaseline,
666
+ hb: startHeap,
631
667
  ha: heapAfter,
668
+ hr: heapRetained,
632
669
  tb: timersBefore,
633
670
  ta: timersAfter,
634
671
  pb: pendingBefore,
@@ -0,0 +1,9 @@
1
+ export interface Watchdog {
2
+ /** Report progress to the feed watchdog. Call it where real work completed,
3
+ * not from a bare timer. A no-op when `watchdog.feed` is not configured. */
4
+ feed(): void
5
+ }
6
+
7
+ /** Progress reporting for the `feed` watchdog. The limits themselves are
8
+ * set in `mikro.config.ts` under `watchdog`, not from code. */
9
+ export declare const watchdog: Watchdog
@@ -0,0 +1,7 @@
1
+ import {feed} from 'native:mikro/watchdog'
2
+
3
+ import type {Watchdog} from './types.js'
4
+
5
+ export type {Watchdog} from './types.js'
6
+
7
+ export const watchdog: Watchdog = {feed}
@@ -19,6 +19,7 @@ int mik__eval_bytecode(JSContext* ctx, const uint8_t* buf, size_t buf_len, bool
19
19
  }
20
20
  }
21
21
 
22
+ mik__blocking_begin(MIK_GetRuntime(ctx));
22
23
  JSValue val = JS_EvalFunction(ctx, obj);
23
24
  if (JS_IsException(val)) {
24
25
  mik_dump_error(ctx);
@@ -1,5 +1,6 @@
1
1
  #include "mikrojs/mikrojs.h"
2
2
 
3
+ #include <climits>
3
4
  #include <cstdio>
4
5
  #include <cstdlib>
5
6
  #include <cstring>
@@ -24,6 +25,9 @@ void MIK_DefaultConfig(MIKConfig* config) {
24
25
  config->log_dir[0] = '\0';
25
26
  config->log_max_size = 64 * 1024;
26
27
  config->log_flush = MIK_LOG_FLUSH_ERROR;
28
+ config->blocking_timeout_ms = MIK_WATCHDOG_BLOCKING_DEFAULT_MS;
29
+ config->feed_timeout_ms = 0;
30
+ config->awake_timeout_ms = 0;
27
31
  }
28
32
 
29
33
  /* Minimal JSON parser for config file — avoids cJSON dependency.
@@ -60,6 +64,34 @@ static bool mik__json_get_number(const char* json, const char* key, double* out)
60
64
  return true;
61
65
  }
62
66
 
67
+ static bool mik__json_is_false(const char* json, const char* key) {
68
+ char pattern[128];
69
+ snprintf(pattern, sizeof(pattern), "\"%s\"", key);
70
+ const char* p = strstr(json, pattern);
71
+ if (!p) return false;
72
+ p += strlen(pattern);
73
+ while (*p == ' ' || *p == '\t' || *p == ':') p++;
74
+ return strncmp(p, "false", 5) == 0;
75
+ }
76
+
77
+ /* Watchdog budget in ms. `false` and 0 disable; other values below 1 s
78
+ * clamp up with a warning, since a smaller budget fires on ordinary work. */
79
+ static int mik__json_get_watchdog_ms(const MIKPlatform* platform, const char* json,
80
+ const char* key, int current) {
81
+ if (mik__json_is_false(json, key)) return 0;
82
+ double num_val;
83
+ if (!mik__json_get_number(json, key, &num_val)) return current;
84
+ if (num_val == 0) return 0;
85
+ if (num_val < 1000) {
86
+ platform->log(MIK_LOG_WARN, TAG, "%s below 1000 ms (%ld); clamping to 1000", key,
87
+ (long)num_val);
88
+ return 1000;
89
+ }
90
+ /* Past INT_MAX the cast would wrap negative and silently disable it. */
91
+ if (num_val > INT_MAX) return INT_MAX;
92
+ return (int)num_val;
93
+ }
94
+
63
95
  /* Read a JSON file into a malloc'd buffer. Caller must free(). Returns NULL on failure. */
64
96
  static char* mik__read_json_file(const char* filepath, struct stat* st) {
65
97
  if (stat(filepath, st) != 0) return nullptr;
@@ -333,6 +365,12 @@ int MIK_LoadConfig(const char* base_path, MIKConfig* config) {
333
365
  if (mik__json_get_number(buf, "onPanic.duration", &num_val)) {
334
366
  config->panic_sleep_duration_ms = (int)num_val;
335
367
  }
368
+ config->blocking_timeout_ms = mik__json_get_watchdog_ms(
369
+ platform, buf, "watchdog.blocking", config->blocking_timeout_ms);
370
+ config->feed_timeout_ms = mik__json_get_watchdog_ms(platform, buf, "watchdog.feed",
371
+ config->feed_timeout_ms);
372
+ config->awake_timeout_ms = mik__json_get_watchdog_ms(
373
+ platform, buf, "watchdog.awake", config->awake_timeout_ms);
336
374
  if (mik__json_get_number(buf, "stackSize", &num_val)) {
337
375
  config->stack_size = (size_t)num_val;
338
376
  }
@@ -263,6 +263,11 @@ bool mik__report_uncaught(JSContext* ctx, JSValue exc, bool in_promise) {
263
263
  }
264
264
  }
265
265
 
266
+ /* A blocking-watchdog timeout reaches here either through mik_dump_error
267
+ * (which already reported it; this is then a no-op) or as an unhandled
268
+ * rejection when the interrupted turn was a module evaluation. */
269
+ mik__watchdog_report_blocking(MIK_GetRuntime(ctx));
270
+
266
271
  /* Fixed-size stack buffer so this function never allocates from the
267
272
  * C++ heap. Previously we used std::string + mik_inspect here; both
268
273
  * can throw std::bad_alloc under memory pressure, and with
@@ -116,6 +116,40 @@ void MIKOtaStore::SetInFlight(bool in_flight) {
116
116
  env_->kv_set_i32(env_->opaque, "ota.inflight", in_flight ? 1 : 0);
117
117
  }
118
118
 
119
+ 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)) ||
122
+ out->checksum[0] == '\0') {
123
+ return false;
124
+ }
125
+ if (!env_->kv_get_str(env_->opaque, "ota.declReason", out->reason, sizeof(out->reason)) ||
126
+ out->reason[0] == '\0') {
127
+ return false;
128
+ }
129
+ if (!env_->kv_get_str(env_->opaque, "ota.declDetail", out->detail, sizeof(out->detail))) {
130
+ out->detail[0] = '\0';
131
+ }
132
+ return true;
133
+ }
134
+
135
+ void MIKOtaStore::SetDecline(const MIKOtaDeclineRecord& record) {
136
+ if (!env_ || !env_->kv_set_str) return;
137
+ env_->kv_set_str(env_->opaque, "ota.declined", record.checksum);
138
+ env_->kv_set_str(env_->opaque, "ota.declReason", record.reason);
139
+ if (record.detail[0]) {
140
+ env_->kv_set_str(env_->opaque, "ota.declDetail", record.detail);
141
+ } else if (env_->kv_remove) {
142
+ env_->kv_remove(env_->opaque, "ota.declDetail");
143
+ }
144
+ }
145
+
146
+ void MIKOtaStore::ClearDecline() {
147
+ if (!env_ || !env_->kv_remove) return;
148
+ env_->kv_remove(env_->opaque, "ota.declined");
149
+ env_->kv_remove(env_->opaque, "ota.declReason");
150
+ env_->kv_remove(env_->opaque, "ota.declDetail");
151
+ }
152
+
119
153
  // ── Offer parsing ────────────────────────────────────────────────────────────
120
154
 
121
155
  bool mik__ota_parse_offer(const char* url, const char* checksum, int64_t size, bool allow_insecure,
package/src/mik_repl.cpp CHANGED
@@ -39,10 +39,11 @@ static MIKReplTransport* repl_transport = nullptr;
39
39
  static uint8_t ready_buf[384];
40
40
  static size_t ready_len = 0;
41
41
 
42
- /* Memory left for the app, captured once when the first runtime attaches: that
43
- * happens before the entry is evaluated, so these describe the floor the app is
44
- * handed rather than whatever is left whenever a client happens to connect.
45
- * Reported on MSG_READY so `mikro profile` is a read, not a deploy. */
42
+ /* Memory left for the app, captured once from the boot path (see
43
+ * MIK_CaptureBootMemory) before the entry is evaluated, so these describe the
44
+ * floor the app is handed rather than whatever is left whenever a client
45
+ * happens to connect. Reported on MSG_READY so reading them is a read, not a
46
+ * deploy. */
46
47
  static bool boot_mem_captured = false;
47
48
  static uint32_t boot_heap_free = 0;
48
49
  static uint32_t boot_system_free = 0;
@@ -119,8 +120,8 @@ void mik__repl_proto_send_output(uint8_t msg_type, const void* data, size_t len)
119
120
  * Returns false on transport EOF/error, when MIK_ProtocolExit() is
120
121
  * signalled from inside the pump (the supervisor's mechanism for ending
121
122
  * a test-file's serve loop after __testFileDone fires), or when MIK_Loop
122
- * reports the attached runtime has halted (e.g. an unhandled rejection
123
- * set stop_requested a test that crashed mid-execution). */
123
+ * reports the attached runtime has halted without a panic grace window
124
+ * (e.g. an unhandled rejection in a test that crashed mid-execution). */
124
125
  bool mik__proto_read_exact(MIKReplTransport* transport, void* buf, size_t n) {
125
126
  const MIKPlatform* platform = MIK_GetPlatform();
126
127
  uint8_t* p = static_cast<uint8_t*>(buf);
@@ -133,11 +134,10 @@ bool mik__proto_read_exact(MIKReplTransport* transport, void* buf, size_t n) {
133
134
  return false;
134
135
  } else {
135
136
  if (repl_mik_rt && !repl_paused) {
136
- if (MIK_Loop(repl_mik_rt) != 0) {
137
- /* Runtime halted (typically unhandled rejection sets
138
- * stop_requested). Further pumping is a no-op, so
139
- * exit the serve loop and let the caller swap in the
140
- * next runtime — or restart, in non-supervisor mode. */
137
+ /* A panic arms restart_at_us and MIK_Loop takes the action
138
+ * itself at the deadline, so keep serving (--recover). Only
139
+ * a plain stop (test supervisor) ends the serve loop. */
140
+ if (MIK_Loop(repl_mik_rt) != 0 && repl_mik_rt->restart_at_us == 0) {
141
141
  s_exit_serve_loop = true;
142
142
  return false;
143
143
  }
@@ -611,6 +611,7 @@ static bool handle_directive_impl(JSContext* ctx, const char* line, std::string&
611
611
  out.append("App is already paused\n");
612
612
  } else {
613
613
  repl_paused = true;
614
+ mik__watchdog_pause_begin(repl_mik_rt);
614
615
  out.append("App paused (timers, callbacks suspended). Use /resume to continue.\n");
615
616
  }
616
617
  return true;
@@ -621,6 +622,7 @@ static bool handle_directive_impl(JSContext* ctx, const char* line, std::string&
621
622
  out.append("App is not paused\n");
622
623
  } else {
623
624
  repl_paused = false;
625
+ mik__watchdog_pause_end(repl_mik_rt);
624
626
  out.append("App resumed\n");
625
627
  }
626
628
  return true;
@@ -852,6 +854,13 @@ bool mik__repl_is_paused(void) {
852
854
  }
853
855
 
854
856
  void mik__repl_set_paused(bool paused) {
857
+ if (paused != repl_paused) {
858
+ if (paused) {
859
+ mik__watchdog_pause_begin(repl_mik_rt);
860
+ } else {
861
+ mik__watchdog_pause_end(repl_mik_rt);
862
+ }
863
+ }
855
864
  repl_paused = paused;
856
865
  }
857
866
 
@@ -1003,23 +1012,26 @@ void MIK_ProtocolOpen(MIKReplTransport* transport) {
1003
1012
 
1004
1013
  }
1005
1014
 
1015
+ void MIK_CaptureBootMemory(MIKRuntime* mik_rt) {
1016
+ if (!mik_rt || boot_mem_captured) return;
1017
+ JSMemoryUsage mem;
1018
+ JS_ComputeMemoryUsage(JS_GetRuntime(MIK_GetJSContext(mik_rt)), &mem);
1019
+ boot_heap_free = mem.malloc_limit > (int64_t)mem.malloc_size
1020
+ ? (uint32_t)(mem.malloc_limit - (int64_t)mem.malloc_size)
1021
+ : 0;
1022
+ boot_system_free = (uint32_t)MIK_GetPlatform()->get_free_system_mem();
1023
+ boot_mem_reserved = mik_rt->config.mem_reserved;
1024
+ boot_mem_captured = true;
1025
+ }
1026
+
1006
1027
  void MIK_ProtocolAttach(MIKRuntime* mik_rt) {
1007
1028
  if (!mik_rt) return;
1008
1029
  repl_ctx = MIK_GetJSContext(mik_rt);
1009
1030
  repl_mik_rt = mik_rt;
1010
- /* First attach only. In test mode a fresh runtime attaches per file, and
1011
- * those would otherwise overwrite the boot floor with per-test figures. */
1012
- if (!boot_mem_captured) {
1013
- JSMemoryUsage mem;
1014
- JS_ComputeMemoryUsage(JS_GetRuntime(repl_ctx), &mem);
1015
- boot_heap_free =
1016
- mem.malloc_limit > (int64_t)mem.malloc_size
1017
- ? (uint32_t)(mem.malloc_limit - (int64_t)mem.malloc_size)
1018
- : 0;
1019
- boot_system_free = (uint32_t)MIK_GetPlatform()->get_free_system_mem();
1020
- boot_mem_reserved = mik_rt->config.mem_reserved;
1021
- boot_mem_captured = true;
1022
- }
1031
+ /* Fallback for boot paths that don't capture explicitly. First attach
1032
+ * only: in test mode a fresh runtime attaches per file, and those would
1033
+ * otherwise overwrite the boot floor with per-test figures. */
1034
+ MIK_CaptureBootMemory(mik_rt);
1023
1035
  }
1024
1036
 
1025
1037
  void MIK_ProtocolDetach(void) {
@@ -1035,6 +1047,9 @@ void MIK_ProtocolClose(void) {
1035
1047
  }
1036
1048
  repl_active = false;
1037
1049
  repl_protocol_mode = false;
1050
+ if (repl_paused) {
1051
+ mik__watchdog_pause_end(repl_mik_rt);
1052
+ }
1038
1053
  repl_paused = false;
1039
1054
  repl_transport = nullptr;
1040
1055
  repl_ctx = nullptr;
@@ -1082,6 +1097,7 @@ void MIK_ProtocolServeLoop(void) {
1082
1097
 
1083
1098
  repl_evaluating = true;
1084
1099
  repl_async_skipped = false;
1100
+ mik__blocking_begin(repl_mik_rt);
1085
1101
  JSValue result = repl_eval_and_pump(ctx, code.c_str(), code.size());
1086
1102
  repl_evaluating = false;
1087
1103
 
@@ -1094,6 +1110,10 @@ void MIK_ProtocolServeLoop(void) {
1094
1110
  if (JS_IsException(result)) {
1095
1111
  JSValue exc = JS_GetException(ctx);
1096
1112
 
1113
+ /* A synchronous throw never reaches mik_dump_error, so
1114
+ * consume the blocking-timeout flag here. */
1115
+ mik__watchdog_report_blocking(repl_mik_rt);
1116
+
1097
1117
  /* Format the error */
1098
1118
  std::string msg = "Uncaught ";
1099
1119
  if (JS_IsObject(exc)) {
@@ -0,0 +1,170 @@
1
+ #include <quickjs.h>
2
+
3
+ #include "mikrojs/mikrojs.h"
4
+ #include "mikrojs/platform.h"
5
+ #include "mikrojs/private.h"
6
+ #include "mikrojs/utils.h"
7
+
8
+ /* ── Watchdog: blocking deadline, feed deadline, awake budget ─────────
9
+ *
10
+ * All three measure wall time via get_boot_us(). The blocking deadline is
11
+ * polled from the QuickJS interrupt handler (every 10k branches/calls);
12
+ * feed and awake are checked once per MIK_Loop pass. State is per-runtime,
13
+ * so the Node addon's parallel runtimes need no atomics. */
14
+
15
+
16
+ void mik__blocking_begin(MIKRuntime* mik_rt) {
17
+ if (!mik_rt) return;
18
+ mik_rt->blocking_start_us = 0;
19
+ mik_rt->blocking_armed = true;
20
+ }
21
+
22
+ int mik__watchdog_interrupt(JSRuntime* rt, void* opaque) {
23
+ (void)rt;
24
+ MIKRuntime* mik_rt = static_cast<MIKRuntime*>(opaque);
25
+ int budget_ms = mik_rt->config.blocking_timeout_ms;
26
+ if (budget_ms <= 0 || !mik_rt->blocking_armed) {
27
+ return 0;
28
+ }
29
+ int64_t now = MIK_GetPlatform()->get_boot_us();
30
+ int64_t start = mik_rt->blocking_start_us;
31
+ /* now < start: the boot clock reset (deep sleep, test platform swap). */
32
+ if (start == 0 || now < start) {
33
+ mik_rt->blocking_start_us = now;
34
+ return 0;
35
+ }
36
+ if (now - start >= (int64_t)budget_ms * 1000) {
37
+ /* One-shot: stay off while the error unwinds so finally blocks
38
+ * and error-handler property reads are not interrupted too. */
39
+ mik_rt->blocking_armed = false;
40
+ mik_rt->blocking_tripped = true;
41
+ /* The app could not feed while it was blocked; give it a fresh
42
+ * window so a surviving session (REPL eval) is not hit twice. */
43
+ mik_rt->feed_last_us = now;
44
+ return 1;
45
+ }
46
+ return 0;
47
+ }
48
+
49
+ bool mik__watchdog_report_blocking(MIKRuntime* mik_rt) {
50
+ if (!mik_rt || !mik_rt->blocking_tripped) {
51
+ return false;
52
+ }
53
+ mik_rt->blocking_tripped = false;
54
+ mik__print_error_line("[watchdog] WATCHDOG TRIGGERED: event loop blocking time exceeded "
55
+ "configured limit of %d ms",
56
+ mik_rt->config.blocking_timeout_ms);
57
+ return true;
58
+ }
59
+
60
+ void mik__watchdog_arm(MIKRuntime* mik_rt) {
61
+ const MIKPlatform* platform = MIK_GetPlatform();
62
+ const MIKConfig* cfg = &mik_rt->config;
63
+ mik__blocking_begin(mik_rt);
64
+ mik_rt->feed_armed = cfg->feed_timeout_ms > 0;
65
+ mik_rt->feed_last_us = platform->get_boot_us();
66
+ mik_rt->awake_armed = cfg->awake_timeout_ms > 0;
67
+ mik_rt->awake_pause_offset_us = 0;
68
+ mik_rt->pause_start_us = 0;
69
+ if (mik_rt->awake_armed && cfg->panic_mode != MIK_PANIC_DEEP_SLEEP) {
70
+ /* Always visible: platform->log is silent on device by default. */
71
+ mik__print_error_line("[watchdog] awake limit of %d ms without onPanic.mode 'deepSleep': "
72
+ "the device restarts every %d ms",
73
+ cfg->awake_timeout_ms, cfg->awake_timeout_ms);
74
+ }
75
+ }
76
+
77
+ void mik__watchdog_check(MIKRuntime* mik_rt) {
78
+ if (mik_rt->stop_requested || (!mik_rt->feed_armed && !mik_rt->awake_armed)) {
79
+ return;
80
+ }
81
+ /* A pending exception (a blocking timeout, or any uncaught throw from the
82
+ * entry) is about to be reported and stop the runtime. Firing feed or
83
+ * awake on top of it would replace that exception and lose its trace. */
84
+ if (JS_HasException(mik_rt->ctx)) {
85
+ return;
86
+ }
87
+ const MIKPlatform* platform = MIK_GetPlatform();
88
+ int64_t now = platform->get_boot_us();
89
+
90
+ if (mik_rt->awake_armed) {
91
+ int64_t elapsed_us = now - mik_rt->awake_pause_offset_us;
92
+ if (elapsed_us >= (int64_t)mik_rt->config.awake_timeout_ms * 1000) {
93
+ mik_rt->awake_armed = false;
94
+ mik__print_error_line("[watchdog] WATCHDOG TRIGGERED: awake time exceeded configured "
95
+ "limit of %d ms",
96
+ mik_rt->config.awake_timeout_ms);
97
+ /* No JS error: nothing to point a trace at, and this must not
98
+ * reach the OTA trial error handler (a slow link is not a bug). */
99
+ MIK_Stop(mik_rt);
100
+ return;
101
+ }
102
+ }
103
+
104
+ if (mik_rt->feed_armed) {
105
+ if (now < mik_rt->feed_last_us) {
106
+ mik_rt->feed_last_us = now;
107
+ } else if (now - mik_rt->feed_last_us >= (int64_t)mik_rt->config.feed_timeout_ms * 1000) {
108
+ mik_rt->feed_armed = false;
109
+ mik__print_error_line("[watchdog] WATCHDOG TRIGGERED: time since last feed() exceeded "
110
+ "configured limit of %d ms",
111
+ mik_rt->config.feed_timeout_ms);
112
+ /* No JS frame is active here, so uncatchable marking is moot: the
113
+ * JS_HasException branch in MIK_Loop routes this before any user
114
+ * code runs. */
115
+ JS_ThrowInternalError(mik_rt->ctx,
116
+ "watchdog: time since last feed() exceeded configured limit "
117
+ "of %d ms",
118
+ mik_rt->config.feed_timeout_ms);
119
+ }
120
+ }
121
+ }
122
+
123
+ void mik__watchdog_wake(MIKRuntime* mik_rt) {
124
+ if (!mik_rt) return;
125
+ mik__blocking_begin(mik_rt);
126
+ /* The app could not feed while asleep; it gets a fresh window, as after
127
+ * a pause. The awake clock keeps counting: light sleep is still uptime. */
128
+ mik_rt->feed_last_us = MIK_GetPlatform()->get_boot_us();
129
+ }
130
+
131
+ void mik__watchdog_pause_begin(MIKRuntime* mik_rt) {
132
+ if (!mik_rt || mik_rt->pause_start_us != 0) return;
133
+ mik_rt->pause_start_us = MIK_GetPlatform()->get_boot_us();
134
+ }
135
+
136
+ void mik__watchdog_pause_end(MIKRuntime* mik_rt) {
137
+ if (!mik_rt || mik_rt->pause_start_us == 0) return;
138
+ int64_t now = MIK_GetPlatform()->get_boot_us();
139
+ if (now > mik_rt->pause_start_us) {
140
+ mik_rt->awake_pause_offset_us += now - mik_rt->pause_start_us;
141
+ }
142
+ mik_rt->pause_start_us = 0;
143
+ /* The app could not feed while suspended; it gets a fresh budget. */
144
+ mik_rt->feed_last_us = now;
145
+ }
146
+
147
+ /* ── native:mikro/watchdog ──────────────────────────────────────────── */
148
+
149
+ static JSValue mik__watchdog_feed(JSContext* ctx, JSValueConst this_val, int argc,
150
+ JSValueConst* argv) {
151
+ (void)this_val;
152
+ (void)argc;
153
+ (void)argv;
154
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
155
+ if (mik_rt && mik_rt->feed_armed) {
156
+ mik_rt->feed_last_us = MIK_GetPlatform()->get_boot_us();
157
+ }
158
+ return JS_UNDEFINED;
159
+ }
160
+
161
+ static int mik__watchdog_module_init(JSContext* ctx, JSModuleDef* m) {
162
+ JS_SetModuleExport(ctx, m, "feed", JS_NewCFunction(ctx, mik__watchdog_feed, "feed", 0));
163
+ return 0;
164
+ }
165
+
166
+ void mik__watchdog_init(JSContext* ctx) {
167
+ JSModuleDef* m = JS_NewCModule(ctx, "native:mikro/watchdog", mik__watchdog_module_init);
168
+ if (!m) return;
169
+ JS_AddModuleExport(ctx, m, "feed");
170
+ }