@mikrojs/native 0.18.1 → 0.18.2
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/include/mikrojs/mikrojs.h +11 -2
- package/include/mikrojs/ota_client.h +0 -3
- package/include/mikrojs/ota_config.h +51 -0
- package/include/mikrojs/private.h +2 -2
- package/package.json +3 -3
- 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 +3 -0
- package/runtime/ota/ota.ts +6 -0
- package/runtime/ota/types.ts +83 -1
- package/src/mik_ota_client.cpp +9 -76
- package/src/mik_ota_config.cpp +137 -0
- package/src/mik_ota_policy.cpp +8 -0
- package/src/mikrojs.cpp +9 -2
|
@@ -234,11 +234,20 @@ bool MIK_ConsumeOOMFlag(MIKRuntime* mik_rt);
|
|
|
234
234
|
void MIK_ReportOOM(MIKRuntime* mik_rt, const MIKOOMEvent* event);
|
|
235
235
|
|
|
236
236
|
/* Per-module opaque data slots (for platform-specific modules to store state).
|
|
237
|
-
*
|
|
237
|
+
*
|
|
238
|
+
* A slot index identifies one module for the lifetime of the process and means
|
|
239
|
+
* the same thing in every runtime, so reserve it once and cache it:
|
|
240
|
+
*
|
|
241
|
+
* static int my_slot = -1;
|
|
242
|
+
* if (my_slot < 0) my_slot = MIK_ReserveModuleSlot();
|
|
243
|
+
*
|
|
244
|
+
* Reserving per runtime instead would hand the same index to two modules as
|
|
245
|
+
* soon as a second runtime initialized a different set of them, and each
|
|
246
|
+
* would then read the other's state through the wrong type. */
|
|
238
247
|
#define MIK_MODULE_DATA_SLOTS 16
|
|
239
248
|
void MIK_SetModuleData(MIKRuntime* mik_rt, int slot, void* data);
|
|
240
249
|
void* MIK_GetModuleData(MIKRuntime* mik_rt, int slot);
|
|
241
|
-
int
|
|
250
|
+
int MIK_ReserveModuleSlot(void);
|
|
242
251
|
|
|
243
252
|
/* Self-registration for native modules (ESP32, board, driver modules).
|
|
244
253
|
* Modules call MIK_REGISTER_MODULE() at file scope. The module loader
|
|
@@ -250,9 +250,6 @@ private:
|
|
|
250
250
|
* policy working normally — already current, a trial still resolving — are
|
|
251
251
|
* not failures and are not reported. */
|
|
252
252
|
void NoteDecline(MIKOtaDeclineReason reason, const std::string& detail);
|
|
253
|
-
/* Apply a config delivered without an offer: it is for the running
|
|
254
|
-
* release. Returns whether stored state changed. */
|
|
255
|
-
bool ApplyRunningConfig(const MIKOtaStoredConfig* config, int trial_boots);
|
|
256
253
|
void ScheduleNext(NextRound next);
|
|
257
254
|
int64_t Jitter(int64_t ms);
|
|
258
255
|
int64_t Now() const;
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* than the one running is ignored, because it was computed against a different
|
|
12
12
|
* release's schema.
|
|
13
13
|
*
|
|
14
|
+
* The writes deliver a document into those slots. The built-in check-in client
|
|
15
|
+
* takes them from its response; an app running its own transport (`ota.parseConfig`
|
|
16
|
+
* / `ota.applyConfig`) takes them from wherever its client got them. Both go
|
|
17
|
+
* through here, so the two cannot disagree about the trial and the baseline.
|
|
18
|
+
*
|
|
14
19
|
* The read answers with an object or throws: there is no "no config yet" value.
|
|
15
20
|
* A build that went through the tooling carries a manifest, the manifest
|
|
16
21
|
* carries the defaults, and an app that declares no config schema gets an empty
|
|
@@ -97,4 +102,50 @@ private:
|
|
|
97
102
|
JSContext* defaults_ctx_ = nullptr;
|
|
98
103
|
};
|
|
99
104
|
|
|
105
|
+
/* ── the writes ──────────────────────────────────────────────────────────── */
|
|
106
|
+
|
|
107
|
+
/* What a write did to the store.
|
|
108
|
+
*
|
|
109
|
+
* `kUnchanged` covers both "identical to what is already held" and "a clear
|
|
110
|
+
* with nothing to clear": nothing moved, and the rev the device echoes is the
|
|
111
|
+
* one it echoed before. `kFailed` is a store that could not answer, which is
|
|
112
|
+
* not the same as nothing to do — the caller must not echo the new rev, so the
|
|
113
|
+
* writer sends the document again. */
|
|
114
|
+
enum class MIKOtaConfigWrite {
|
|
115
|
+
kUnchanged,
|
|
116
|
+
kApplied,
|
|
117
|
+
kCleared,
|
|
118
|
+
kStaged,
|
|
119
|
+
kFailed,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const char* mik__ota_config_write_to_str(MIKOtaConfigWrite write);
|
|
123
|
+
|
|
124
|
+
/* Deliver a document to the RUNNING release: the document it replaces becomes
|
|
125
|
+
* the rollback baseline, and a trial of `trial_boots` is armed, because a
|
|
126
|
+
* schema-valid value can still be fatal to the app. A NULL config, or one whose
|
|
127
|
+
* doc is absent, is the clear. */
|
|
128
|
+
MIKOtaConfigWrite mik__ota_apply_running_config(const MIKOtaEnv* env,
|
|
129
|
+
const MIKOtaStoredConfig* config,
|
|
130
|
+
int trial_boots);
|
|
131
|
+
|
|
132
|
+
/* Stage a document alongside an offered build: it was computed for that
|
|
133
|
+
* release, so it applies at its trial boot, with the build. A NULL config, or
|
|
134
|
+
* one whose doc is absent, stages the clear: the new release holds no document
|
|
135
|
+
* and its manifest defaults stand in. */
|
|
136
|
+
void mik__ota_stage_next_config(const MIKOtaEnv* env, const MIKOtaStoredConfig* config);
|
|
137
|
+
|
|
138
|
+
/* Deliver a document whose slot is not known from context, which is the case
|
|
139
|
+
* for a client that receives one over its own transport: the version stamp
|
|
140
|
+
* decides. Stamped for the running release it is applied, anything else is
|
|
141
|
+
* staged for the build it names. */
|
|
142
|
+
MIKOtaConfigWrite mik__ota_deliver_config(const MIKOtaEnv* env, const MIKOtaStoredConfig* config,
|
|
143
|
+
int trial_boots);
|
|
144
|
+
|
|
145
|
+
/* Settle a running-release trial on the health signal a completed check-in is.
|
|
146
|
+
* Adopts only once the app has READ the document: a check-in completing before
|
|
147
|
+
* the app ever ran with the new values proves nothing about them. Returns
|
|
148
|
+
* whether a trial was settled. */
|
|
149
|
+
bool mik__ota_adopt_config_trial(const MIKOtaEnv* env);
|
|
150
|
+
|
|
100
151
|
} // namespace mikrojs
|
|
@@ -123,9 +123,9 @@ struct MIKRuntime {
|
|
|
123
123
|
std::vector<std::pair<std::string, std::string>> env_vars;
|
|
124
124
|
/* Opaque per-module data slots for platform-specific modules.
|
|
125
125
|
* Used by registered native modules to store their state on the runtime.
|
|
126
|
-
*
|
|
126
|
+
* Indexed by MIK_ReserveModuleSlot(), which is process-wide, so a slot
|
|
127
|
+
* means the same module in every runtime. */
|
|
127
128
|
void* module_data[MIK_MODULE_DATA_SLOTS] = {};
|
|
128
|
-
int next_module_slot = 0;
|
|
129
129
|
/* Virtual modules: name → JS source. Checked by the module loader before
|
|
130
130
|
* the builtin bytecode table, allowing host-side JS to override any
|
|
131
131
|
* native:* C module (e.g. mocking device modules for desktop dev). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikrojs/native",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"description": "Mikro.js C++ runtime library and Node.js native addon",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"esp32",
|
|
@@ -88,14 +88,14 @@
|
|
|
88
88
|
"cmake-js": "^8.0.0",
|
|
89
89
|
"node-addon-api": "^8.7.0",
|
|
90
90
|
"node-gyp-build": "^4.8.4",
|
|
91
|
-
"@mikrojs/quickjs": "0.18.
|
|
91
|
+
"@mikrojs/quickjs": "0.18.2"
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
94
|
"@swc/core": "^1.15.30",
|
|
95
95
|
"@types/node": "^24.12.2",
|
|
96
96
|
"esbuild": "^0.28.0",
|
|
97
97
|
"terser": "^5.46.2",
|
|
98
|
-
"@mikrojs/registry": "0.18.
|
|
98
|
+
"@mikrojs/registry": "0.18.2"
|
|
99
99
|
},
|
|
100
100
|
"engines": {
|
|
101
101
|
"node": ">=24.0.0"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runtime/internal.d.ts
CHANGED
|
@@ -546,6 +546,9 @@ declare module 'native:mikro/ota_client' {
|
|
|
546
546
|
export const running: Ota['running']
|
|
547
547
|
export const parseOffer: Ota['parseOffer']
|
|
548
548
|
export const applyOffer: Ota['applyOffer']
|
|
549
|
+
export const parseConfig: Ota['parseConfig']
|
|
550
|
+
export const applyConfig: Ota['applyConfig']
|
|
551
|
+
export const configState: Ota['configState']
|
|
549
552
|
export const confirm: Ota['confirm']
|
|
550
553
|
export const revert: Ota['revert']
|
|
551
554
|
export const bearer: Ota['bearer']
|
package/runtime/ota/ota.ts
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
import {config} from 'mikro/ota/config'
|
|
10
10
|
import {
|
|
11
|
+
applyConfig,
|
|
11
12
|
applyOffer,
|
|
12
13
|
bearer,
|
|
14
|
+
configState,
|
|
13
15
|
confirm,
|
|
16
|
+
parseConfig,
|
|
14
17
|
parseOffer,
|
|
15
18
|
reconcile,
|
|
16
19
|
registry,
|
|
@@ -30,6 +33,9 @@ const ota: Ota = {
|
|
|
30
33
|
bearer,
|
|
31
34
|
registry,
|
|
32
35
|
config,
|
|
36
|
+
parseConfig,
|
|
37
|
+
applyConfig,
|
|
38
|
+
configState,
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export {ota}
|
package/runtime/ota/types.ts
CHANGED
|
@@ -150,6 +150,44 @@ export interface ConfigTrial {
|
|
|
150
150
|
read: boolean
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** What a config write did to the store. Only `applied` and `cleared` change
|
|
154
|
+
* what the running build reads; `staged` changes what the next one will. */
|
|
155
|
+
export type ConfigWrite =
|
|
156
|
+
/** Stored as the running build's config. The document it replaced is kept as
|
|
157
|
+
* the rollback baseline and a trial is armed, so the next `ota.confirm()`
|
|
158
|
+
* after the app has read it is what keeps it. */
|
|
159
|
+
| 'applied'
|
|
160
|
+
/** Stored for the release the document names, which is not the one running.
|
|
161
|
+
* It applies when that build installs, with the build. */
|
|
162
|
+
| 'staged'
|
|
163
|
+
/** The document was removed. The manifest defaults stand alone again. */
|
|
164
|
+
| 'cleared'
|
|
165
|
+
/** Nothing moved: the document is identical to the one already held (rev
|
|
166
|
+
* included), or it was a clear with nothing to clear. */
|
|
167
|
+
| 'unchanged'
|
|
168
|
+
/** 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. */
|
|
172
|
+
| '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. */
|
|
175
|
+
| 'invalid'
|
|
176
|
+
|
|
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
|
+
|
|
153
191
|
/** A config document rolled back after a failed trial; reported on check-ins
|
|
154
192
|
* while it stands. `rev` names the failed document, and the client keeps
|
|
155
193
|
* echoing it as `configRev`, which is what stops the registry re-serving
|
|
@@ -197,7 +235,11 @@ export interface Ota {
|
|
|
197
235
|
download: DownloadFn,
|
|
198
236
|
options?: InstallOptions,
|
|
199
237
|
): Promise<Result<ApplyOutcome, OtaError>>
|
|
200
|
-
/** Mark the running trial as healthy so it is kept rather than rolled back.
|
|
238
|
+
/** Mark the running trial as healthy so it is kept rather than rolled back.
|
|
239
|
+
* Settles a delivered config document's trial by the same call: a completed
|
|
240
|
+
* check-in is the health signal both of them wait for. A config trial waits
|
|
241
|
+
* additionally for the app to have read the document, so a `confirm()` from
|
|
242
|
+
* an app that never called `config()` keeps nothing. */
|
|
201
243
|
confirm(): void
|
|
202
244
|
/** Reinstall the previous build immediately. */
|
|
203
245
|
revert(): Result<void, OtaInstallError>
|
|
@@ -236,6 +278,46 @@ export interface Ota {
|
|
|
236
278
|
* fixable states, never a value the app has to branch on.
|
|
237
279
|
*/
|
|
238
280
|
config<T = RegisteredConfig>(): T
|
|
281
|
+
/**
|
|
282
|
+
* Validate a config document a client received over its own transport, or
|
|
283
|
+
* `undefined` when it cannot be used. What `parseOffer` is to an offer.
|
|
284
|
+
*
|
|
285
|
+
* A usable document is an object with a non-empty `version` (the release it
|
|
286
|
+
* was computed for, which decides where `applyConfig` puts it), an optional
|
|
287
|
+
* `rev` short enough to echo intact, and a `doc` that is an object or absent.
|
|
288
|
+
* An absent `doc` is the clear, not a malformed document.
|
|
289
|
+
*
|
|
290
|
+
* Whether the document survives CBOR is settled by `applyConfig`, which is
|
|
291
|
+
* where the stored bytes are made.
|
|
292
|
+
*/
|
|
293
|
+
parseConfig(raw: unknown): StoredConfig | undefined
|
|
294
|
+
/**
|
|
295
|
+
* Store a config document, for a client that received one over its own
|
|
296
|
+
* transport. The built-in client covers the ordinary case and does not go
|
|
297
|
+
* through here.
|
|
298
|
+
*
|
|
299
|
+
* The `version` stamp decides where it lands: stamped for the running
|
|
300
|
+
* release it is applied, and the document it replaces is kept as the
|
|
301
|
+
* rollback baseline; stamped for another it is staged for the build it
|
|
302
|
+
* names, to apply when that build installs. The return value says which
|
|
303
|
+
* happened, and says when nothing did.
|
|
304
|
+
*
|
|
305
|
+
* A delivery to the running release arms a trial. Each boot whose first
|
|
306
|
+
* `config()` read serves the document burns one of `trialBoots`, and the
|
|
307
|
+
* budget spent with no `confirm()` in between restores the previous
|
|
308
|
+
* document. On a wake-cycle device every wake is a boot, so raise
|
|
309
|
+
* `trialBoots` above the default when a check-in can plausibly fail a few
|
|
310
|
+
* cycles in a row.
|
|
311
|
+
*/
|
|
312
|
+
applyConfig(config: StoredConfig, options?: {trialBoots?: number}): ConfigWrite
|
|
313
|
+
/**
|
|
314
|
+
* What the device owes its registry about config: the rev to echo, and a
|
|
315
|
+
* rolled-back document to report. A client that builds its own check-in body
|
|
316
|
+
* needs both. Without the echo the registry re-serves the same document on
|
|
317
|
+
* every check-in; without the report a document that took the device down is
|
|
318
|
+
* re-served forever and nobody is told.
|
|
319
|
+
*/
|
|
320
|
+
configState(): ConfigState
|
|
239
321
|
}
|
|
240
322
|
|
|
241
323
|
/** The `mikro/ota` singleton. The runtime value is provided by the on-device
|
package/src/mik_ota_client.cpp
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
#include <vector>
|
|
11
11
|
|
|
12
12
|
#include "mikrojs/cbor_helpers.h"
|
|
13
|
+
#include "mikrojs/ota_config.h"
|
|
13
14
|
#include "mikrojs/ota_slots.h"
|
|
14
15
|
#include "mikrojs/platform.h"
|
|
15
16
|
|
|
@@ -800,26 +801,13 @@ void MIKOtaClient::OnCheckInSettled() {
|
|
|
800
801
|
// Confirm before applying: a completed check-in is the whole health signal
|
|
801
802
|
// require_confirm waits for, whether or not an offer follows — and resolving
|
|
802
803
|
// the trial now lets an offer published during it stage in this same pass.
|
|
804
|
+
// It settles a running-release config delivery's trial too (see
|
|
805
|
+
// mik__ota_policy_confirm): the same signal, for the same reason.
|
|
803
806
|
if (running_.trial) {
|
|
804
807
|
Log(MIK_LOG_INFO, "ota: check-in completed — confirming this build as healthy");
|
|
805
808
|
}
|
|
806
809
|
mik__ota_policy_confirm(env_);
|
|
807
810
|
|
|
808
|
-
// A completed check-in is the health signal for BOTH trials: the build's
|
|
809
|
-
// (confirmed above) and a running-release config delivery's. The config
|
|
810
|
-
// trial additionally waits for the app to have READ the document — a
|
|
811
|
-
// check-in completing before the app ever ran with the new values proves
|
|
812
|
-
// nothing about them.
|
|
813
|
-
// A read that FAILED is not a read that found nothing: it fails under the
|
|
814
|
-
// heap pressure this check-in just created, and adopting on it would take a
|
|
815
|
-
// document the app never read.
|
|
816
|
-
MIKOtaConfigTrial config_trial = {};
|
|
817
|
-
MIKOtaKvStatus trial_status = mik__ota_load_trial(env_, &config_trial);
|
|
818
|
-
if (trial_status == MIK_OTA_KV_ABSENT || (trial_status == MIK_OTA_KV_OK && config_trial.read)) {
|
|
819
|
-
mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
|
|
820
|
-
mik__ota_clear_trial(env_);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
811
|
bool has_url_key = false;
|
|
824
812
|
std::string offer_url;
|
|
825
813
|
std::string offer_checksum;
|
|
@@ -939,8 +927,11 @@ void MIKOtaClient::OnCheckInSettled() {
|
|
|
939
927
|
if (!has_url_key || !offer_valid) {
|
|
940
928
|
// OR in this boot's promote/restore, delivered once: the config those
|
|
941
929
|
// applied is as new to the app as one this round delivered.
|
|
942
|
-
|
|
943
|
-
|
|
930
|
+
MIKOtaConfigWrite write = mik__ota_apply_running_config(
|
|
931
|
+
env_, have_response_config_ ? &response_config_ : nullptr,
|
|
932
|
+
active_.options.trial_boots);
|
|
933
|
+
bool updated =
|
|
934
|
+
write == MIKOtaConfigWrite::kApplied || write == MIKOtaConfigWrite::kCleared;
|
|
944
935
|
updated = updated || boot_config_changed_;
|
|
945
936
|
boot_config_changed_ = false;
|
|
946
937
|
Log(MIK_LOG_DEBUG, "ota: up to date, running the latest build");
|
|
@@ -1109,12 +1100,7 @@ void MIKOtaClient::OnDownloadSettled() {
|
|
|
1109
1100
|
// with the build, applied together at the trial boot. An absent doc is the
|
|
1110
1101
|
// clear, so it stages "the new release holds no document" and the manifest
|
|
1111
1102
|
// defaults stand in.
|
|
1112
|
-
|
|
1113
|
-
mik__ota_store_slot(env_, MIK_OTA_CFG_NEXT, response_config_);
|
|
1114
|
-
Log(MIK_LOG_INFO, "ota: config staged for %s", response_config_.version);
|
|
1115
|
-
} else {
|
|
1116
|
-
mik__ota_clear_slot(env_, MIK_OTA_CFG_NEXT);
|
|
1117
|
-
}
|
|
1103
|
+
mik__ota_stage_next_config(env_, have_response_config_ ? &response_config_ : nullptr);
|
|
1118
1104
|
|
|
1119
1105
|
Log(MIK_LOG_INFO, "ota: download verified and staged");
|
|
1120
1106
|
result_.status = MIKOtaCheckStatus::kStaged;
|
|
@@ -1122,59 +1108,6 @@ void MIKOtaClient::OnDownloadSettled() {
|
|
|
1122
1108
|
FinishRound(NextRound::kLater);
|
|
1123
1109
|
}
|
|
1124
1110
|
|
|
1125
|
-
bool MIKOtaClient::ApplyRunningConfig(const MIKOtaStoredConfig* config, int trial_boots) {
|
|
1126
|
-
if (!config) return false;
|
|
1127
|
-
|
|
1128
|
-
if (!config->doc_cbor || config->doc_cbor_len == 0) {
|
|
1129
|
-
// A rev riding along does not turn a clear into a document.
|
|
1130
|
-
mik__ota_clear_trial(env_);
|
|
1131
|
-
mik__ota_clear_config_error(env_);
|
|
1132
|
-
mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
|
|
1133
|
-
if (!mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT).present) return false;
|
|
1134
|
-
mik__ota_clear_slot(env_, MIK_OTA_CFG_CURRENT);
|
|
1135
|
-
Log(MIK_LOG_INFO, "ota: config cleared by the registry");
|
|
1136
|
-
return true;
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
MIKOtaLoadedConfig previous = mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT);
|
|
1140
|
-
|
|
1141
|
-
// Without the document being replaced there is no baseline to roll back to,
|
|
1142
|
-
// and clearing the one already stored would strand a bad document with
|
|
1143
|
-
// nowhere to fall back to. Leave everything alone; the rev the device
|
|
1144
|
-
// echoes is unchanged, so the registry sends this again next round.
|
|
1145
|
-
if (previous.failed) return false;
|
|
1146
|
-
|
|
1147
|
-
// A registry is meant to send config only when the rev the device echoed
|
|
1148
|
-
// differs, but one that sends it every round must not cost anything: taking
|
|
1149
|
-
// an identical document as a change would re-write NVS on every round, put
|
|
1150
|
-
// the document back on trial it had already passed, and tell the app its
|
|
1151
|
-
// config changed when nothing did. The rev counts as part of the document —
|
|
1152
|
-
// it is what the device echoes, so a new one has to be stored and echoed
|
|
1153
|
-
// back even when the values are the same.
|
|
1154
|
-
if (previous.present && strcmp(previous.cfg.rev, config->rev) == 0 &&
|
|
1155
|
-
strcmp(previous.cfg.version, config->version) == 0 &&
|
|
1156
|
-
previous.cfg.doc_cbor_len == config->doc_cbor_len &&
|
|
1157
|
-
(config->doc_cbor_len == 0 ||
|
|
1158
|
-
memcmp(previous.cfg.doc_cbor, config->doc_cbor, config->doc_cbor_len) == 0)) {
|
|
1159
|
-
return false;
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
// The old document is kept as the rollback baseline: a schema-valid value
|
|
1163
|
-
// can still be fatal to the app (a GPIO this board does not have), and the
|
|
1164
|
-
// crash it causes can fire before any check-in runs.
|
|
1165
|
-
if (previous.present) {
|
|
1166
|
-
mik__ota_store_slot(env_, MIK_OTA_CFG_PREV, previous.cfg);
|
|
1167
|
-
} else {
|
|
1168
|
-
mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
|
|
1169
|
-
}
|
|
1170
|
-
mik__ota_store_slot(env_, MIK_OTA_CFG_CURRENT, *config);
|
|
1171
|
-
MIKOtaConfigTrial trial = {trial_boots, false};
|
|
1172
|
-
mik__ota_store_trial(env_, trial);
|
|
1173
|
-
mik__ota_clear_config_error(env_);
|
|
1174
|
-
Log(MIK_LOG_INFO, "ota: config updated for %s", config->version);
|
|
1175
|
-
return true;
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
1111
|
void MIKOtaClient::FinishRound(NextRound next) {
|
|
1179
1112
|
// Teardown before any restart: the round is over either way, and the hook's
|
|
1180
1113
|
// invariant — teardown runs whenever setup succeeded — must not depend on
|
package/src/mik_ota_config.cpp
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
#include "mikrojs/cbor_helpers.h"
|
|
12
12
|
#include "mikrojs/ota_slots.h"
|
|
13
|
+
#include "mikrojs/platform.h"
|
|
13
14
|
|
|
14
15
|
namespace mikrojs {
|
|
15
16
|
|
|
@@ -293,4 +294,140 @@ JSValue MIKOtaConfigReader::Defaults(JSContext* ctx) {
|
|
|
293
294
|
return defaults_;
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
/* ── the writes ──────────────────────────────────────────────────────────── */
|
|
298
|
+
|
|
299
|
+
namespace {
|
|
300
|
+
|
|
301
|
+
/* `arg` fills the one %s a config log line ever carries. */
|
|
302
|
+
void LogConfig(const MIKOtaEnv* env, int level, const char* fmt, const char* arg = nullptr) {
|
|
303
|
+
if (!env || !env->log) return;
|
|
304
|
+
if (arg) {
|
|
305
|
+
env->log(env->opaque, level, fmt, arg);
|
|
306
|
+
} else {
|
|
307
|
+
env->log(env->opaque, level, "%s", fmt);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
} // namespace
|
|
312
|
+
|
|
313
|
+
const char* mik__ota_config_write_to_str(MIKOtaConfigWrite write) {
|
|
314
|
+
switch (write) {
|
|
315
|
+
case MIKOtaConfigWrite::kUnchanged:
|
|
316
|
+
return "unchanged";
|
|
317
|
+
case MIKOtaConfigWrite::kApplied:
|
|
318
|
+
return "applied";
|
|
319
|
+
case MIKOtaConfigWrite::kCleared:
|
|
320
|
+
return "cleared";
|
|
321
|
+
case MIKOtaConfigWrite::kStaged:
|
|
322
|
+
return "staged";
|
|
323
|
+
case MIKOtaConfigWrite::kFailed:
|
|
324
|
+
return "failed";
|
|
325
|
+
}
|
|
326
|
+
return "unknown";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
MIKOtaConfigWrite mik__ota_apply_running_config(const MIKOtaEnv* env,
|
|
330
|
+
const MIKOtaStoredConfig* config,
|
|
331
|
+
int trial_boots) {
|
|
332
|
+
if (!config) return MIKOtaConfigWrite::kUnchanged;
|
|
333
|
+
|
|
334
|
+
if (!config->doc_cbor || config->doc_cbor_len == 0) {
|
|
335
|
+
// A rev riding along does not turn a clear into a document.
|
|
336
|
+
mik__ota_clear_trial(env);
|
|
337
|
+
mik__ota_clear_config_error(env);
|
|
338
|
+
mik__ota_clear_slot(env, MIK_OTA_CFG_PREV);
|
|
339
|
+
MIKOtaLoadedConfig held = mik__ota_load_slot(env, MIK_OTA_CFG_CURRENT);
|
|
340
|
+
if (held.failed) return MIKOtaConfigWrite::kFailed;
|
|
341
|
+
if (!held.present) return MIKOtaConfigWrite::kUnchanged;
|
|
342
|
+
mik__ota_clear_slot(env, MIK_OTA_CFG_CURRENT);
|
|
343
|
+
LogConfig(env, MIK_LOG_INFO, "ota: config cleared");
|
|
344
|
+
return MIKOtaConfigWrite::kCleared;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
MIKOtaLoadedConfig previous = mik__ota_load_slot(env, MIK_OTA_CFG_CURRENT);
|
|
348
|
+
|
|
349
|
+
// Without the document being replaced there is no baseline to roll back to,
|
|
350
|
+
// and clearing the one already stored would strand a bad document with
|
|
351
|
+
// nowhere to fall back to. Leave everything alone; the rev the device
|
|
352
|
+
// echoes is unchanged, so the writer sends this again.
|
|
353
|
+
if (previous.failed) return MIKOtaConfigWrite::kFailed;
|
|
354
|
+
|
|
355
|
+
// A registry is meant to send config only when the rev the device echoed
|
|
356
|
+
// differs, but one that sends it every round must not cost anything: taking
|
|
357
|
+
// an identical document as a change would re-write NVS on every round, put
|
|
358
|
+
// the document back on trial it had already passed, and tell the app its
|
|
359
|
+
// config changed when nothing did. The rev counts as part of the document —
|
|
360
|
+
// it is what the device echoes, so a new one has to be stored and echoed
|
|
361
|
+
// back even when the values are the same.
|
|
362
|
+
if (previous.present && strcmp(previous.cfg.rev, config->rev) == 0 &&
|
|
363
|
+
strcmp(previous.cfg.version, config->version) == 0 &&
|
|
364
|
+
previous.cfg.doc_cbor_len == config->doc_cbor_len &&
|
|
365
|
+
(config->doc_cbor_len == 0 ||
|
|
366
|
+
memcmp(previous.cfg.doc_cbor, config->doc_cbor, config->doc_cbor_len) == 0)) {
|
|
367
|
+
return MIKOtaConfigWrite::kUnchanged;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The old document is kept as the rollback baseline: a schema-valid value
|
|
371
|
+
// can still be fatal to the app (a GPIO this board does not have), and the
|
|
372
|
+
// crash it causes can fire before any check-in runs.
|
|
373
|
+
if (previous.present) {
|
|
374
|
+
mik__ota_store_slot(env, MIK_OTA_CFG_PREV, previous.cfg);
|
|
375
|
+
} else {
|
|
376
|
+
mik__ota_clear_slot(env, MIK_OTA_CFG_PREV);
|
|
377
|
+
}
|
|
378
|
+
mik__ota_store_slot(env, MIK_OTA_CFG_CURRENT, *config);
|
|
379
|
+
MIKOtaConfigTrial trial = {trial_boots, false};
|
|
380
|
+
mik__ota_store_trial(env, trial);
|
|
381
|
+
mik__ota_clear_config_error(env);
|
|
382
|
+
LogConfig(env, MIK_LOG_INFO, "ota: config updated for %s", config->version);
|
|
383
|
+
return MIKOtaConfigWrite::kApplied;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
void mik__ota_stage_next_config(const MIKOtaEnv* env, const MIKOtaStoredConfig* config) {
|
|
387
|
+
if (config && config->doc_cbor && config->doc_cbor_len > 0) {
|
|
388
|
+
mik__ota_store_slot(env, MIK_OTA_CFG_NEXT, *config);
|
|
389
|
+
LogConfig(env, MIK_LOG_INFO, "ota: config staged for %s", config->version);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// The clear is staged too: the offered release holds no document, and its
|
|
393
|
+
// manifest defaults stand in once it runs.
|
|
394
|
+
mik__ota_clear_slot(env, MIK_OTA_CFG_NEXT);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
MIKOtaConfigWrite mik__ota_deliver_config(const MIKOtaEnv* env, const MIKOtaStoredConfig* config,
|
|
398
|
+
int trial_boots) {
|
|
399
|
+
if (!config) return MIKOtaConfigWrite::kUnchanged;
|
|
400
|
+
|
|
401
|
+
char running[32] = {};
|
|
402
|
+
if (!env || !env->read_app_version ||
|
|
403
|
+
!env->read_app_version(env->opaque, running, sizeof(running))) {
|
|
404
|
+
// The version the document has to be matched against could not be read,
|
|
405
|
+
// so which slot it belongs in is unknown. Storing it in either would be
|
|
406
|
+
// a guess, and the reader drops a document stamped for another release
|
|
407
|
+
// without a sound, so the guess would fail silently.
|
|
408
|
+
return MIKOtaConfigWrite::kFailed;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (strcmp(config->version, running) == 0) {
|
|
412
|
+
return mik__ota_apply_running_config(env, config, trial_boots);
|
|
413
|
+
}
|
|
414
|
+
// Stamped for another release: it is the document that release will read,
|
|
415
|
+
// and it applies with the build, not before it.
|
|
416
|
+
mik__ota_stage_next_config(env, config);
|
|
417
|
+
return MIKOtaConfigWrite::kStaged;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
bool mik__ota_adopt_config_trial(const MIKOtaEnv* env) {
|
|
421
|
+
// A read that FAILED is not a read that found nothing: it fails under heap
|
|
422
|
+
// pressure, and adopting on it would keep a document the app never read.
|
|
423
|
+
MIKOtaConfigTrial trial = {};
|
|
424
|
+
MIKOtaKvStatus status = mik__ota_load_trial(env, &trial);
|
|
425
|
+
// With no trial in progress the clears are housekeeping, not a settlement.
|
|
426
|
+
bool settled = status == MIK_OTA_KV_OK && trial.read;
|
|
427
|
+
if (status != MIK_OTA_KV_ABSENT && !settled) return false;
|
|
428
|
+
mik__ota_clear_slot(env, MIK_OTA_CFG_PREV);
|
|
429
|
+
mik__ota_clear_trial(env);
|
|
430
|
+
return settled;
|
|
431
|
+
}
|
|
432
|
+
|
|
296
433
|
} // namespace mikrojs
|
package/src/mik_ota_policy.cpp
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
#include <memory>
|
|
6
6
|
#include <string>
|
|
7
7
|
|
|
8
|
+
#include "mikrojs/ota_config.h"
|
|
8
9
|
#include "mikrojs/platform.h"
|
|
9
10
|
|
|
10
11
|
namespace mikrojs {
|
|
@@ -205,6 +206,13 @@ void mik__ota_policy_confirm(const MIKOtaEnv* env) {
|
|
|
205
206
|
if (env && env->mark_valid) {
|
|
206
207
|
env->mark_valid(env->opaque);
|
|
207
208
|
}
|
|
209
|
+
// One confirm settles both trials. A completed check-in is the health
|
|
210
|
+
// signal each of them waits for, so an app running its own client gets the
|
|
211
|
+
// config trial resolved by the same call the built-in client makes — and
|
|
212
|
+
// the two cannot drift into settling one trial but not the other. The
|
|
213
|
+
// config trial has its own extra gate (the app must have read the
|
|
214
|
+
// document), which lives in mik__ota_adopt_config_trial.
|
|
215
|
+
mik__ota_adopt_config_trial(env);
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
namespace {
|
package/src/mikrojs.cpp
CHANGED
|
@@ -533,8 +533,15 @@ void* MIK_GetModuleData(MIKRuntime* mik_rt, int slot) {
|
|
|
533
533
|
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
|
534
534
|
mik_module_desc_t* mik__module_registry_head = nullptr;
|
|
535
535
|
|
|
536
|
-
int
|
|
537
|
-
|
|
536
|
+
int MIK_ReserveModuleSlot(void) {
|
|
537
|
+
/* Process-wide and never released: a module keeps one index for the life
|
|
538
|
+
* of the process, so every runtime agrees on what that index holds. A
|
|
539
|
+
* per-runtime counter would restart at zero and alias modules across
|
|
540
|
+
* runtimes. Callers reserve once (guard on their cached slot being < 0);
|
|
541
|
+
* reserving per runtime would exhaust the table instead. */
|
|
542
|
+
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
|
543
|
+
static int next_slot = 0;
|
|
544
|
+
int slot = next_slot++;
|
|
538
545
|
CHECK(slot < MIK_MODULE_DATA_SLOTS);
|
|
539
546
|
return slot;
|
|
540
547
|
}
|