@mikrojs/native 0.19.0 → 0.20.0-next.20260904225731

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
@@ -61,6 +61,7 @@ set(MIKROJS_CORE_SOURCES
61
61
  src/mik_ota_js_hooks.cpp
62
62
  src/mik_ota_client.cpp
63
63
  src/mik_sys_codec.cpp
64
+ src/mik_watchdog.cpp
64
65
  )
65
66
 
66
67
  add_library(mikrojs STATIC
@@ -91,7 +92,7 @@ endif()
91
92
  include(cmake/mikrojs_bytecode.cmake)
92
93
  mikrojs_generate_bytecode(
93
94
  RUNTIME_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime"
94
- MODULES abort cbor env result schema fs i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client ota/config pin pwm reader sleep spi sntp stdio stream sys test uart udp
95
+ MODULES abort cbor env result schema fs i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client ota/config pin pwm reader sleep spi sntp stdio stream sys test uart udp watchdog
95
96
  MODULE_PREFIX "mikro"
96
97
  SYMBOL_PREFIX "mikro"
97
98
  TARGET gen_bytecode
@@ -222,6 +223,7 @@ if(BUILD_TESTING)
222
223
  test/ota_wire_fixtures_test.cpp
223
224
  test/schema_conformance_test.cpp
224
225
  test/sys_codec_test.cpp
226
+ test/watchdog_test.cpp
225
227
  )
226
228
 
227
229
  # ── Check-in wire fixtures ───────────────────────────────────────
@@ -54,8 +54,18 @@ typedef struct MIKConfig {
54
54
  char log_dir[64];
55
55
  uint32_t log_max_size;
56
56
  MIKLogFlush log_flush;
57
+ /* Watchdog budgets in ms, 0 = disabled. Mirror of the `watchdog` key in
58
+ * the host-side TS config. blocking: one event-loop turn holding the
59
+ * loop; feed: gap between watchdog.feed() calls; awake: time since boot. */
60
+ int blocking_timeout_ms;
61
+ int feed_timeout_ms;
62
+ int awake_timeout_ms;
57
63
  } MIKConfig;
58
64
 
65
+ /* Shipped blocking default. The ESP32 side static_asserts this against the
66
+ * hardware TWDT timeout so the two cannot drift out of order. */
67
+ #define MIK_WATCHDOG_BLOCKING_DEFAULT_MS 30000
68
+
59
69
  void MIK_DefaultConfig(MIKConfig* config);
60
70
  int MIK_LoadConfig(const char* base_path, MIKConfig* config);
61
71
  void MIK_SetConfig(MIKRuntime* mik_rt, const MIKConfig* config);
@@ -51,6 +51,18 @@ public:
51
51
 
52
52
  using MIKOtaDownloadFn = std::function<bool(MIKOtaUpdate& update, MIKOtaError* out_err)>;
53
53
 
54
+ /* Why the last offered build was not taken, in wire-string form, held in the
55
+ * store until a completed check-in delivers it. Persisted rather than held in
56
+ * memory (unlike the built-in client's copy) because an own-transport decline
57
+ * happens after that wake's check-in, so it must survive a deep sleep to make
58
+ * the next report. Field caps match the registry's checkin validation, which
59
+ * rejects a body whose lastDecline oversteps them. */
60
+ struct MIKOtaDeclineRecord {
61
+ char checksum[65] = {};
62
+ char reason[65] = {};
63
+ char detail[257] = {};
64
+ };
65
+
54
66
  class MIKOtaStore {
55
67
  public:
56
68
  explicit MIKOtaStore(const MIKOtaEnv* env) : env_(env) {}
@@ -75,6 +87,11 @@ public:
75
87
  bool GetInFlight() const;
76
88
  void SetInFlight(bool in_flight);
77
89
 
90
+ /* False when no record stands. */
91
+ bool GetDecline(MIKOtaDeclineRecord* out) const;
92
+ void SetDecline(const MIKOtaDeclineRecord& record);
93
+ void ClearDecline();
94
+
78
95
  private:
79
96
  const MIKOtaEnv* env_;
80
97
  };
@@ -19,6 +19,9 @@ typedef struct MIKPlatform {
19
19
  * deep sleep (hosts), where callers fall back to restart(). */
20
20
  void (*deep_sleep_us)(uint64_t us);
21
21
  void (*yield)(void);
22
+ /** Feed the hardware task watchdog for the calling task. NULL where the
23
+ * platform has none (hosts) or the watchdog is compiled out. */
24
+ void (*feed_watchdog)(void);
22
25
  size_t (*get_free_system_mem)(void);
23
26
  size_t (*get_min_free_system_mem)(void); /* All-time low watermark */
24
27
  size_t (*get_total_system_mem)(void);
@@ -80,6 +80,21 @@ struct MIKRuntime {
80
80
  * the host can still deploy/clean/--recover during the grace window,
81
81
  * then platform->restart() fires from MIK_Loop. */
82
82
  int64_t restart_at_us;
83
+ /* Watchdog state (mik_watchdog.cpp). All per-runtime, no atomics needed.
84
+ * blocking_start_us == 0 means "stamp on the next interrupt poll"; the
85
+ * handler switches itself off when it fires and back on at mik__blocking_begin. */
86
+ int64_t blocking_start_us;
87
+ bool blocking_armed;
88
+ /* Set when the limit is hit, consumed by the error dump that reports it. */
89
+ bool blocking_tripped;
90
+ /* Feed deadline: last feed()/re-stamp; feed_armed clears when it fires. */
91
+ int64_t feed_last_us;
92
+ bool feed_armed;
93
+ /* Awake budget: deploy-pause time credited against get_boot_us(). */
94
+ int64_t awake_pause_offset_us;
95
+ bool awake_armed;
96
+ /* Boot-clock stamp of the current pause; 0 = not paused. */
97
+ int64_t pause_start_us;
83
98
  const char* fs_base_path;
84
99
  const char* fs_root; /* Sandbox root for mikrojs/fs operations (separate from module resolution) */
85
100
  size_t fs_limit; /* Max bytes for fs_root (0 = unlimited) */
@@ -296,6 +311,31 @@ JSModuleDef* mik__udp_init(JSContext* ctx);
296
311
  JSModuleDef* mik__observable_init(JSContext* ctx);
297
312
  void mik__observable_dispatch_free(struct MIKRuntime* mik_rt);
298
313
 
314
+ /* Watchdog (mik_watchdog.cpp). */
315
+ /* Start a fresh blocking budget: top of each MIK_Loop pass, eval entry
316
+ * points, REPL eval, and on return from a deliberately blocking native
317
+ * (lightSleep). One store; the interrupt handler stamps on its next poll. */
318
+ void mik__blocking_begin(struct MIKRuntime* mik_rt);
319
+ /* JS_SetInterruptHandler callback; opaque is the MIKRuntime*. */
320
+ int mik__watchdog_interrupt(JSRuntime* rt, void* opaque);
321
+ /* If the blocking deadline was hit, log the watchdog line and clear the
322
+ * flag. Returns whether it did; callers print the error trace after. */
323
+ bool mik__watchdog_report_blocking(struct MIKRuntime* mik_rt);
324
+ /* Switch feed/awake on from mik_rt->config. Called once the config is set. */
325
+ void mik__watchdog_arm(struct MIKRuntime* mik_rt);
326
+ /* Feed and awake checks. Runs at the top of MIK_Loop after the restart_at_us
327
+ * branch; a feed miss throws an uncatchable error into mik_rt->ctx, on an
328
+ * awake overrun calls MIK_Stop directly. */
329
+ void mik__watchdog_check(struct MIKRuntime* mik_rt);
330
+ /* On return from lightSleep: fresh blocking budget and feed window. */
331
+ void mik__watchdog_wake(struct MIKRuntime* mik_rt);
332
+ /* Deploy/REPL pause bookkeeping: re-stamp the feed clock and credit the
333
+ * paused span to the awake budget. */
334
+ void mik__watchdog_pause_begin(struct MIKRuntime* mik_rt);
335
+ void mik__watchdog_pause_end(struct MIKRuntime* mik_rt);
336
+ /* Registers native:mikro/watchdog (feed) on ctx. */
337
+ void mik__watchdog_init(JSContext* ctx);
338
+
299
339
  bool mik__repl_is_evaluating(void);
300
340
 
301
341
  /* REPL protocol mode (mik_repl.cpp) — used by mik_console.cpp, mik_stdio.cpp */
@@ -68,6 +68,11 @@ void mik_assert(const struct AssertionInfo info);
68
68
  }
69
69
 
70
70
  void mik_call_handler(JSContext* ctx, JSValue func, int argc, JSValue* argv);
71
+ /* Print one always-visible error line the way console.error does: an error
72
+ * frame in protocol mode, stderr otherwise. platform->log is filtered per
73
+ * tag on the device and silent by default, so lines that precede a reboot
74
+ * (watchdog lines, panic actions) go through here instead. */
75
+ void mik__print_error_line(const char* fmt, ...) __attribute__((format(printf, 1, 2)));
71
76
  void mik_dump_error(JSContext* ctx);
72
77
  void mik_dump_error1(JSContext* ctx, JSValue exception_val);
73
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.19.0",
3
+ "version": "0.20.0-next.20260904225731",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -80,21 +80,22 @@
80
80
  "./runtime/test/types": "./runtime/test/types.ts",
81
81
  "./runtime/uart/types": "./runtime/uart/types.ts",
82
82
  "./runtime/udp/types": "./runtime/udp/types.ts",
83
+ "./runtime/watchdog/types": "./runtime/watchdog/types.ts",
83
84
  "./runtime/wifi/types": "./runtime/wifi/types.ts"
84
85
  },
85
86
  "dependencies": {
86
87
  "cmake-js": "^8.0.0",
87
88
  "node-addon-api": "^8.7.0",
88
89
  "node-gyp-build": "^4.8.4",
89
- "@mikrojs/quickjs": "0.19.0"
90
+ "@mikrojs/quickjs": "0.20.0-next.20260904225731+071a2a9"
90
91
  },
91
92
  "devDependencies": {
92
93
  "@swc/core": "^1.15.30",
93
94
  "@types/node": "^24.12.2",
94
95
  "esbuild": "^0.28.0",
95
96
  "terser": "^5.46.2",
96
- "@mikrojs/registry": "0.19.0",
97
- "@mikrojs/schema": "0.19.0"
97
+ "@mikrojs/registry": "0.20.0-next.20260904225731+071a2a9",
98
+ "@mikrojs/schema": "0.20.0-next.20260904225731+071a2a9"
98
99
  },
99
100
  "engines": {
100
101
  "node": ">=24.0.0"
@@ -170,6 +170,10 @@ declare module 'native:mikro/pin' {
170
170
  export function analogReadMillivolts(pin: number, attenuation: number): Result<number, PinError>
171
171
  }
172
172
 
173
+ declare module 'native:mikro/watchdog' {
174
+ export function feed(): void
175
+ }
176
+
173
177
  declare module 'native:mikro/sleep' {
174
178
  import type {DeepWakeupSources, LightWakeupSources} from '@mikrojs/native/runtime/sleep/types'
175
179
 
@@ -580,12 +584,8 @@ declare module 'native:mikro/ota_client' {
580
584
 
581
585
  // The mikro/ota policy surface.
582
586
  export const reconcile: Ota['reconcile']
583
- export const running: Ota['running']
584
- export const parseOffer: Ota['parseOffer']
585
587
  export const applyOffer: Ota['applyOffer']
586
- export const parseConfig: Ota['parseConfig']
587
- export const applyConfig: Ota['applyConfig']
588
- export const configState: Ota['configState']
588
+ export const decline: Ota['decline']
589
589
  export const report: Ota['report']
590
590
  export const settle: Ota['settle']
591
591
  export const confirm: Ota['confirm']
@@ -8,18 +8,14 @@
8
8
 
9
9
  import {config} from 'mikro/ota/config'
10
10
  import {
11
- applyConfig,
12
11
  applyOffer,
13
12
  bearer,
14
- configState,
15
13
  confirm,
16
- parseConfig,
17
- parseOffer,
14
+ decline,
18
15
  reconcile,
19
16
  registry,
20
17
  report,
21
18
  revert,
22
- running,
23
19
  settle,
24
20
  } from 'native:mikro/ota_client'
25
21
 
@@ -27,17 +23,13 @@ import type {Ota} from './types.js'
27
23
 
28
24
  const ota: Ota = {
29
25
  reconcile,
30
- running,
31
- parseOffer,
32
26
  applyOffer,
33
27
  confirm,
34
28
  revert,
35
29
  bearer,
36
30
  registry,
37
31
  config,
38
- parseConfig,
39
- applyConfig,
40
- configState,
32
+ decline,
41
33
  report,
42
34
  settle,
43
35
  }
@@ -6,7 +6,8 @@ import type {Result} from '../result/types.js'
6
6
  /** An update offered by a registry: what to fetch and how to verify it, nothing
7
7
  * more. Whether this device should take it is the registry's decision, made
8
8
  * against the firmware and bytecode version the device reports at check-in and
9
- * the whole set of builds it holds. Validate untrusted input with `parseOffer`. */
9
+ * the whole set of builds it holds. `settle()` validates the untrusted
10
+ * response fields into one. */
10
11
  export interface Offer {
11
12
  /** https URL of the .tgz build */
12
13
  url: string
@@ -117,24 +118,6 @@ export type ApplyOutcome =
117
118
  * mismatch code, since it cannot tell a bad hash from bad bytes. */
118
119
  export type OtaError = OtaWriteError | OtaBeginError | OtaInstallError | OtaDownloadError
119
120
 
120
- /** One stored config document: the complete effective config computed and
121
- * validated by the registry (or by the CLI at cable-seed time), the opaque
122
- * token that identifies it, and the release version it was computed for.
123
- * The device stores and returns it without understanding it: validation is
124
- * the writer's job, and the registry already ships the code. */
125
- export interface StoredConfig {
126
- /** Opaque registry-issued token echoed as `configRev` on check-ins. The
127
- * registry serves its document whenever the echo differs from its own
128
- * current rev, so a document it does not recognize is replaced. */
129
- rev?: string
130
- /** The release version this document was computed for. A document stamped
131
- * for another version is ignored by `ota.config()`. */
132
- version: string
133
- /** The served document: the deviation overlay the read resolves over the
134
- * build's manifest defaults. */
135
- doc?: unknown
136
- }
137
-
138
121
  /** The three config slots, mirroring the build's install slots: `current` is
139
122
  * what the running build reads, `next` is staged with an offered build,
140
123
  * `prev` is the rollback baseline while a trial is unresolved. */
@@ -166,28 +149,13 @@ export type ConfigWrite =
166
149
  * included), or it was a clear with nothing to clear. */
167
150
  | 'unchanged'
168
151
  /** Nothing was written, and the reason is transient: the store could not
169
- * answer, or the running version could not be read. Keep echoing the rev
170
- * from `configState()` so the document is served again, rather than the rev
171
- * of the document that did not land. */
152
+ * answer, or the running version could not be read. The next `report()`
153
+ * still echoes the held rev, so the document is served again, rather than
154
+ * the rev of the document that did not land. */
172
155
  | 'failed'
173
- /** Not a usable config document. Validate with `parseConfig` first to find
174
- * out before the delivery, and log what the wire actually carried. */
156
+ /** Not a usable config document; log what the wire actually carried. */
175
157
  | 'invalid'
176
158
 
177
- /** What a check-in body owes the registry about config, for a client that
178
- * builds its own. */
179
- export interface ConfigState {
180
- /** The rev to send as `configRev`: the registry serves its document whenever
181
- * this differs from its own current rev. Absent when the device holds no
182
- * document. After a rolled-back trial this is the FAILED document's rev,
183
- * which is what stops the registry serving it again. */
184
- rev?: string
185
- /** A document that failed its trial and was rolled back, reported until it
186
- * is replaced. Send it as `configError`, or an operator has no way to see
187
- * that the document they published took the device down. */
188
- error?: ConfigErrorReport
189
- }
190
-
191
159
  /** A config document rolled back after a failed trial; reported on check-ins
192
160
  * while it stands. `rev` names the failed document, and the client keeps
193
161
  * echoing it as `configRev`, which is what stops the registry re-serving
@@ -197,6 +165,19 @@ export interface ConfigErrorReport {
197
165
  message: string
198
166
  }
199
167
 
168
+ /** Why the last offered build was not taken. Without it a registry that waits
169
+ * for `running.checksum` to turn into the offered checksum cannot tell a
170
+ * device still working through a download from one that has permanently
171
+ * stopped trying. */
172
+ export interface DeclineReport {
173
+ /** The declined build, 64 lowercase hex characters. */
174
+ checksum: string
175
+ /** A module outcome ({@link DeclineReason}) or the app's own word for a
176
+ * reason only it can see, at most 64 characters. */
177
+ reason: DeclineReason | (string & {})
178
+ detail?: string
179
+ }
180
+
200
181
  /** The check-in body a device owes its registry, as `ota.report()` builds it.
201
182
  * Field shapes match the wire, so a client (or the proxy behind it) can
202
183
  * forward fields verbatim into `POST /api/v1/checkin`. */
@@ -217,10 +198,17 @@ export interface CheckinReport {
217
198
  /** Why a previous install failed. Present after a `reconcile()` that found
218
199
  * one, until a `settle()` marks it delivered. */
219
200
  lastInstall?: Diagnostic
220
- /** The held document's rev to echo; see {@link ConfigState.rev}. */
201
+ /** The held document's rev to echo: the registry serves its document
202
+ * whenever this differs from its own current rev. After a rolled-back
203
+ * trial this is the FAILED document's rev, which is what stops the
204
+ * registry serving it again. */
221
205
  configRev?: string
222
- /** A rolled-back document to report; see {@link ConfigState.error}. */
206
+ /** A rolled-back document to report; see {@link ConfigErrorReport}. */
223
207
  configError?: ConfigErrorReport
208
+ /** Why the last offered build was not taken. Present after `applyOffer`
209
+ * declined one or the app recorded its own via `decline()`, until a
210
+ * `settle()` marks it delivered. */
211
+ lastDecline?: DeclineReport
224
212
  }
225
213
 
226
214
  /** What `ota.settle()` took from a completed check-in's response. */
@@ -260,15 +248,14 @@ export type RegisteredConfig = keyof OtaConfig extends never ? unknown : OtaConf
260
248
  export interface Ota {
261
249
  /** Report what happened to a previous update on this boot, and clear the report. */
262
250
  reconcile(): InstallOutcome
263
- /** The build currently executing, read from the live app. */
264
- running(): RunningBuild
265
- /** Validate a registry value into an `Offer`, or `undefined` if unusable.
266
- * `allowInsecure` (dev only) accepts an http build url instead of https. */
267
- parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offer | undefined
268
251
  /** Run the full update policy: skip checks and the retry limit, download
269
252
  * via the `download` callback, and verification. Compatibility is not
270
253
  * re-checked: the registry selected this build for the reported firmware,
271
- * and a mismatched archive fails its checksum or fails to load. */
254
+ * and a mismatched archive fails its checksum or fails to load.
255
+ *
256
+ * An offer it does not stage is recorded for the registry the way
257
+ * `decline()` records one, except `'current'` and `'trial-pending'`,
258
+ * which are not declines. */
272
259
  applyOffer(
273
260
  offer: Offer,
274
261
  download: DownloadFn,
@@ -318,50 +305,29 @@ export interface Ota {
318
305
  */
319
306
  config<T = RegisteredConfig>(): T
320
307
  /**
321
- * Validate a config document a client received over its own transport, or
322
- * `undefined` when it cannot be used. What `parseOffer` is to an offer.
323
- *
324
- * A usable document is an object with a non-empty `version` (the release it
325
- * was computed for, which decides where `applyConfig` puts it), an optional
326
- * `rev` short enough to echo intact, and a `doc` that is an object or absent.
327
- * An absent `doc` is the clear, not a malformed document.
328
- *
329
- * Whether the document survives CBOR is settled by `applyConfig`, which is
330
- * where the stored bytes are made.
331
- */
332
- parseConfig(raw: unknown): StoredConfig | undefined
333
- /**
334
- * Store a config document, for a client that received one over its own
335
- * transport. The built-in client covers the ordinary case and does not go
336
- * through here.
337
- *
338
- * The `version` stamp decides where it lands: stamped for the running
339
- * release it is applied, and the document it replaces is kept as the
340
- * rollback baseline; stamped for another it is staged for the build it
341
- * names, to apply when that build installs. The return value says which
342
- * happened, and says when nothing did.
308
+ * Record why an offered build was not taken, for the next `report()` to
309
+ * carry as `lastDecline` and a later `settle()` to mark delivered — the
310
+ * same lifecycle as `lastInstall`. `applyOffer` records its own outcomes,
311
+ * so this is for a reason only the app can see: its own download budget
312
+ * spent, a build its policy refuses. Without a recorded decline the
313
+ * registry cannot tell a device still working through a download from one
314
+ * that has stopped, and shows the update pending forever.
343
315
  *
344
- * A delivery to the running release arms a trial. Each boot whose first
345
- * `config()` read serves the document burns one of `trialBoots`, and the
346
- * budget spent with no `confirm()` in between restores the previous
347
- * document. On a wake-cycle device every wake is a boot, so raise
348
- * `trialBoots` above the default when a check-in can plausibly fail a few
349
- * cycles in a row.
350
- */
351
- applyConfig(config: StoredConfig, options?: {trialBoots?: number}): ConfigWrite
352
- /**
353
- * What the device owes its registry about config: the rev to echo, and a
354
- * rolled-back document to report. A client that builds its own check-in body
355
- * needs both. Without the echo the registry re-serves the same document on
356
- * every check-in; without the report a document that took the device down is
357
- * re-served forever and nobody is told.
316
+ * Persisted on the device, so it survives a deep sleep: on a wake-cycle
317
+ * device the decline always lands after that wake's check-in, and the next
318
+ * wake's `report()` is its first chance to travel. Recording again
319
+ * overwrites; only the last decline stands. Throws a `TypeError` on a
320
+ * checksum that is not 64 lowercase hex characters or a `reason` past 64
321
+ * characters, since the registry would reject the whole check-in body;
322
+ * `detail` is free text and is cut to 256 characters instead.
358
323
  */
359
- configState(): ConfigState
324
+ decline(checksum: string, reason: DeclineReason | (string & {}), detail?: string): void
360
325
  /**
361
326
  * The check-in body the device owes its registry, assembled: identity,
362
- * `running()`, the name pair, free storage, the pending `lastInstall`
363
- * report, and what `configState()` resolves. One call instead of gathering
364
- * the fields by hand, and the same facts the built-in client sends.
327
+ * the running build, the name pair, free storage, the pending `lastInstall`
328
+ * report, a recorded `lastDecline`, and the config echo. One
329
+ * call instead of gathering the fields by hand, and the same facts the
330
+ * built-in client sends.
365
331
  *
366
332
  * Call `reconcile()` first, on every boot: it is what surfaces the
367
333
  * `lastInstall` report this reads (`settle()` marks it delivered).
@@ -370,9 +336,12 @@ export interface Ota {
370
336
  /**
371
337
  * Take a COMPLETED check-in's response, whole: confirm the running trial
372
338
  * and a read config document's trial (exactly `confirm()`), adopt a
373
- * delivered name pair, store a delivered config document (exactly
374
- * `applyConfig`, with `trialBoots`), and validate the offer fields (exactly
375
- * `parseOffer(raw)`, with `allowInsecure`). An empty or null response is
339
+ * delivered name pair, store a delivered config document, and validate the
340
+ * offer fields (`allowInsecure`, dev only, accepts an http build url). A
341
+ * stored document arms a trial of `trialBoots` boots: each boot whose first
342
+ * `config()` read serves it burns one, and the budget spent with no
343
+ * `confirm()` in between restores the previous document, so raise it above
344
+ * the default on a wake-cycle device. An empty or null response is
376
345
  * the registry's quiet round: nothing to deliver, and the confirm still
377
346
  * happens, which is the point of calling. A response of any other shape
378
347
  * never decoded: a captive portal handed back HTML, or a proxy sent an
@@ -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
@@ -119,8 +119,8 @@ void mik__repl_proto_send_output(uint8_t msg_type, const void* data, size_t len)
119
119
  * Returns false on transport EOF/error, when MIK_ProtocolExit() is
120
120
  * signalled from inside the pump (the supervisor's mechanism for ending
121
121
  * 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). */
122
+ * reports the attached runtime has halted without a panic grace window
123
+ * (e.g. an unhandled rejection in a test that crashed mid-execution). */
124
124
  bool mik__proto_read_exact(MIKReplTransport* transport, void* buf, size_t n) {
125
125
  const MIKPlatform* platform = MIK_GetPlatform();
126
126
  uint8_t* p = static_cast<uint8_t*>(buf);
@@ -133,11 +133,10 @@ bool mik__proto_read_exact(MIKReplTransport* transport, void* buf, size_t n) {
133
133
  return false;
134
134
  } else {
135
135
  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. */
136
+ /* A panic arms restart_at_us and MIK_Loop takes the action
137
+ * itself at the deadline, so keep serving (--recover). Only
138
+ * a plain stop (test supervisor) ends the serve loop. */
139
+ if (MIK_Loop(repl_mik_rt) != 0 && repl_mik_rt->restart_at_us == 0) {
141
140
  s_exit_serve_loop = true;
142
141
  return false;
143
142
  }
@@ -611,6 +610,7 @@ static bool handle_directive_impl(JSContext* ctx, const char* line, std::string&
611
610
  out.append("App is already paused\n");
612
611
  } else {
613
612
  repl_paused = true;
613
+ mik__watchdog_pause_begin(repl_mik_rt);
614
614
  out.append("App paused (timers, callbacks suspended). Use /resume to continue.\n");
615
615
  }
616
616
  return true;
@@ -621,6 +621,7 @@ static bool handle_directive_impl(JSContext* ctx, const char* line, std::string&
621
621
  out.append("App is not paused\n");
622
622
  } else {
623
623
  repl_paused = false;
624
+ mik__watchdog_pause_end(repl_mik_rt);
624
625
  out.append("App resumed\n");
625
626
  }
626
627
  return true;
@@ -852,6 +853,13 @@ bool mik__repl_is_paused(void) {
852
853
  }
853
854
 
854
855
  void mik__repl_set_paused(bool paused) {
856
+ if (paused != repl_paused) {
857
+ if (paused) {
858
+ mik__watchdog_pause_begin(repl_mik_rt);
859
+ } else {
860
+ mik__watchdog_pause_end(repl_mik_rt);
861
+ }
862
+ }
855
863
  repl_paused = paused;
856
864
  }
857
865
 
@@ -1035,6 +1043,9 @@ void MIK_ProtocolClose(void) {
1035
1043
  }
1036
1044
  repl_active = false;
1037
1045
  repl_protocol_mode = false;
1046
+ if (repl_paused) {
1047
+ mik__watchdog_pause_end(repl_mik_rt);
1048
+ }
1038
1049
  repl_paused = false;
1039
1050
  repl_transport = nullptr;
1040
1051
  repl_ctx = nullptr;
@@ -1082,6 +1093,7 @@ void MIK_ProtocolServeLoop(void) {
1082
1093
 
1083
1094
  repl_evaluating = true;
1084
1095
  repl_async_skipped = false;
1096
+ mik__blocking_begin(repl_mik_rt);
1085
1097
  JSValue result = repl_eval_and_pump(ctx, code.c_str(), code.size());
1086
1098
  repl_evaluating = false;
1087
1099
 
@@ -1094,6 +1106,10 @@ void MIK_ProtocolServeLoop(void) {
1094
1106
  if (JS_IsException(result)) {
1095
1107
  JSValue exc = JS_GetException(ctx);
1096
1108
 
1109
+ /* A synchronous throw never reaches mik_dump_error, so
1110
+ * consume the blocking-timeout flag here. */
1111
+ mik__watchdog_report_blocking(repl_mik_rt);
1112
+
1097
1113
  /* Format the error */
1098
1114
  std::string msg = "Uncaught ";
1099
1115
  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
+ }
package/src/mikrojs.cpp CHANGED
@@ -262,6 +262,9 @@ MIKRuntime* MIK_NewRuntimeInternal(MIKRunOptions* options) {
262
262
 
263
263
  memcpy(&mik_rt->options, options, sizeof(*options));
264
264
  MIK_DefaultConfig(&mik_rt->config);
265
+ /* Runtimes that never see MIK_SetConfig (host tests, the Node addon)
266
+ * still get the default blocking budget. */
267
+ mik__watchdog_arm(mik_rt);
265
268
 
266
269
  /* Switch the QuickJS heap to PSRAM before constructing the runtime,
267
270
  * so JS_NewRuntime2 and every subsequent QuickJS allocation lands
@@ -346,6 +349,9 @@ MIKRuntime* MIK_NewRuntimeInternal(MIKRunOptions* options) {
346
349
  /* unhandled promise rejection tracker */
347
350
  JS_SetHostPromiseRejectionTracker(rt, mik__promise_rejection_tracker, NULL);
348
351
 
352
+ /* Blocking watchdog: polled every 10k branches/calls. */
353
+ JS_SetInterruptHandler(rt, mik__watchdog_interrupt, mik_rt);
354
+
349
355
  /* Register internal C modules */
350
356
  JSModuleDef* sys_mod = JS_NewCModule(ctx, "native:mikro/sys", mik__sys_module_init);
351
357
  CHECK_NOT_NULL(sys_mod);
@@ -360,6 +366,7 @@ MIKRuntime* MIK_NewRuntimeInternal(MIKRunOptions* options) {
360
366
  mik__result_init(ctx);
361
367
  mik__cbor_init(ctx);
362
368
  mik__udp_init(ctx);
369
+ mik__watchdog_init(ctx);
363
370
  mik__observable_init(ctx);
364
371
 
365
372
  /* Native mikrojs modules (replace bytecode builtins). The http client
@@ -666,13 +673,21 @@ void mik__execute_jobs(JSContext* ctx) {
666
673
 
667
674
  /* main loop which calls the user JS callbacks */
668
675
  int MIK_Loop(MIKRuntime* mik_rt) {
676
+ const MIKPlatform* platform = MIK_GetPlatform();
677
+ /* Feed the hardware watchdog before the grace-window return below, so
678
+ * a long onPanic.delay never counts against it. */
679
+ if (platform->feed_watchdog) {
680
+ platform->feed_watchdog();
681
+ }
682
+ /* One blocking budget per pass, not per job: a fresh budget per job
683
+ * would let `while (true) await 0` spin forever unnoticed. */
684
+ mik__blocking_begin(mik_rt);
669
685
  /* Deferred restart (see MIK_Stop): once the grace window elapses, reboot
670
686
  * the device. While we're still in the window, return 0 without pumping
671
687
  * timers/consumers so the protocol serve loop keeps reading host
672
688
  * commands without firing any more user JS on the dead runtime. The
673
689
  * serve loop also skips its microtask drain on this condition. */
674
690
  if (mik_rt->restart_at_us > 0) {
675
- const MIKPlatform* platform = MIK_GetPlatform();
676
691
  if (platform->get_boot_us() >= mik_rt->restart_at_us) {
677
692
  /* The grace window has elapsed; take the configured panic action.
678
693
  * In deep-sleep mode the timer wake reboots the chip, so the wake
@@ -680,12 +695,18 @@ int MIK_Loop(MIKRuntime* mik_rt) {
680
695
  * design (the CPU is suspended). If the platform has no deep-sleep
681
696
  * hook (hosts), fall through to a plain restart. */
682
697
  if (mik_rt->config.panic_mode == MIK_PANIC_DEEP_SLEEP && platform->deep_sleep_us) {
698
+ mik__print_error_line("[panic] deep-sleeping for %d ms",
699
+ mik_rt->config.panic_sleep_duration_ms);
683
700
  platform->deep_sleep_us((uint64_t)mik_rt->config.panic_sleep_duration_ms * 1000);
684
701
  }
702
+ mik__print_error_line("[panic] restarting");
685
703
  platform->restart();
686
704
  }
687
705
  return 0;
688
706
  }
707
+ /* Feed/awake deadlines: a feed miss lands in the exception branch below,
708
+ * an awake overrun in the stop_requested one. */
709
+ mik__watchdog_check(mik_rt);
689
710
  if (mik_rt->stop_requested) {
690
711
  return 1;
691
712
  }
@@ -730,6 +751,7 @@ void MIK_SetConfig(MIKRuntime* mik_rt, const MIKConfig* config) {
730
751
  if (config->fs_read_max > 0) {
731
752
  mik_rt->fs_read_max = config->fs_read_max;
732
753
  }
754
+ mik__watchdog_arm(mik_rt);
733
755
  }
734
756
 
735
757
  /* Record a profile entry for the entry module loaded via MIK_EvalModule.
@@ -850,6 +872,15 @@ void MIK_Stop(MIKRuntime* mik_rt) {
850
872
  const MIKPlatform* platform = MIK_GetPlatform();
851
873
  mik_rt->restart_at_us =
852
874
  platform->get_boot_us() + (int64_t)mik_rt->config.panic_restart_delay_ms * 1000;
875
+ /* Say what comes next: with deep sleep the device goes silent. */
876
+ if (mik_rt->config.panic_mode == MIK_PANIC_DEEP_SLEEP && platform->deep_sleep_us) {
877
+ mik__print_error_line("[panic] deep-sleeping for %d ms in %d ms",
878
+ mik_rt->config.panic_sleep_duration_ms,
879
+ mik_rt->config.panic_restart_delay_ms);
880
+ } else {
881
+ mik__print_error_line("[panic] restarting in %d ms",
882
+ mik_rt->config.panic_restart_delay_ms);
883
+ }
853
884
  }
854
885
  }
855
886
 
@@ -974,6 +1005,7 @@ JSValue MIK_EvalModuleContent(JSContext* ctx, const char* filename, const char*
974
1005
  const char* eval_src = pp_source ? pp_source : content;
975
1006
  size_t eval_len = pp_source ? pp_len : len;
976
1007
 
1008
+ mik__blocking_begin(mik_rt);
977
1009
  /* Compile then run to be able to set import.meta */
978
1010
  JSValue ret =
979
1011
  JS_Eval(ctx, eval_src, eval_len, filename, JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
@@ -987,6 +1019,7 @@ JSValue MIK_EvalModuleContent(JSContext* ctx, const char* filename, const char*
987
1019
  }
988
1020
 
989
1021
  JSValue MIK_EvalScriptContent(JSContext* ctx, const char* content, size_t len) {
1022
+ mik__blocking_begin(MIK_GetRuntime(ctx));
990
1023
  return JS_Eval(ctx, content, len, "<eval>", JS_EVAL_TYPE_GLOBAL);
991
1024
  }
992
1025
 
@@ -1009,6 +1042,7 @@ JSValue MIK_EvalScript(JSContext* ctx, const char* filename) {
1009
1042
  /* Add null termination, required by JS_Eval. */
1010
1043
  dbuf_putc(&dbuf, '\0');
1011
1044
 
1045
+ mik__blocking_begin(MIK_GetRuntime(ctx));
1012
1046
  ret = JS_Eval(ctx, (char*)dbuf.buf, dbuf_size - 1, filename, JS_EVAL_TYPE_GLOBAL);
1013
1047
 
1014
1048
  dbuf_free(&dbuf);
@@ -1054,6 +1088,7 @@ JSValue MIK_EvalModule(JSContext* ctx, const char* filename, bool is_main) {
1054
1088
  dbuf_size = dbuf.size;
1055
1089
 
1056
1090
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
1091
+ mik__blocking_begin(mik_rt);
1057
1092
  bool profile = mik_rt != nullptr && mik_rt->profile_enabled;
1058
1093
  int64_t malloc_before = 0;
1059
1094
  size_t entries_before = 0;
package/src/utils.cpp CHANGED
@@ -7,6 +7,7 @@
7
7
  #include <string.h>
8
8
 
9
9
  #include "mikrojs/mikrojs.h"
10
+ #include "mikrojs/platform.h"
10
11
  #include "mikrojs/private.h"
11
12
 
12
13
  JSValue mik_new_error(JSContext* ctx, int err) {
@@ -38,7 +39,27 @@ static void mik_dump_obj(JSContext* ctx, FILE* f, JSValue val) {
38
39
  }
39
40
  }
40
41
 
42
+ void mik__print_error_line(const char* fmt, ...) {
43
+ char buf[256];
44
+ va_list args;
45
+ va_start(args, fmt);
46
+ int n = vsnprintf(buf, sizeof(buf) - 2, fmt, args);
47
+ va_end(args);
48
+ if (n < 0) return;
49
+ size_t len = (size_t)n;
50
+ /* vsnprintf wrote at most sizeof(buf) - 3 chars plus a NUL. */
51
+ if (len > sizeof(buf) - 3) len = sizeof(buf) - 3;
52
+ if (mik__repl_is_protocol_mode()) {
53
+ mik__repl_proto_send_output(MIK_MSG_ERROR, buf, len);
54
+ return;
55
+ }
56
+ buf[len++] = '\r';
57
+ buf[len++] = '\n';
58
+ MIK_GetPlatform()->stderr_write(buf, len);
59
+ }
60
+
41
61
  void mik_dump_error(JSContext* ctx) {
62
+ mik__watchdog_report_blocking(MIK_GetRuntime(ctx));
42
63
  JSValue exception_val = JS_GetException(ctx);
43
64
  mik_dump_error1(ctx, exception_val);
44
65
  JS_FreeValue(ctx, exception_val);