@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 +3 -1
- package/include/mikrojs/mikrojs.h +10 -0
- package/include/mikrojs/ota_policy.h +17 -0
- package/include/mikrojs/platform.h +3 -0
- package/include/mikrojs/private.h +40 -0
- package/include/mikrojs/utils.h +5 -0
- package/package.json +5 -4
- package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
- package/runtime/internal.d.ts +5 -5
- package/runtime/ota/ota.ts +2 -10
- package/runtime/ota/types.ts +58 -89
- package/runtime/watchdog/types.ts +9 -0
- package/runtime/watchdog/watchdog.ts +7 -0
- package/src/eval_bytecode.cpp +1 -0
- package/src/mik_app_config.cpp +38 -0
- package/src/mik_console.cpp +5 -0
- package/src/mik_ota_policy.cpp +34 -0
- package/src/mik_repl.cpp +23 -7
- package/src/mik_watchdog.cpp +170 -0
- package/src/mikrojs.cpp +36 -1
- package/src/utils.cpp +21 -0
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 */
|
package/include/mikrojs/utils.h
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
97
|
-
"@mikrojs/schema": "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"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runtime/internal.d.ts
CHANGED
|
@@ -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
|
|
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']
|
package/runtime/ota/ota.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
39
|
-
applyConfig,
|
|
40
|
-
configState,
|
|
32
|
+
decline,
|
|
41
33
|
report,
|
|
42
34
|
settle,
|
|
43
35
|
}
|
package/runtime/ota/types.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
170
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
322
|
-
* `
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
363
|
-
* report,
|
|
364
|
-
* the fields by hand, and the same facts the
|
|
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
|
|
374
|
-
* `
|
|
375
|
-
*
|
|
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
|
package/src/eval_bytecode.cpp
CHANGED
package/src/mik_app_config.cpp
CHANGED
|
@@ -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
|
}
|
package/src/mik_console.cpp
CHANGED
|
@@ -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
|
package/src/mik_ota_policy.cpp
CHANGED
|
@@ -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
|
|
123
|
-
*
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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);
|