@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.
@@ -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
- * Slots are allocated dynamically via MIK_AllocModuleSlot(). */
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 MIK_AllocModuleSlot(MIKRuntime* mik_rt);
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
- * Slots are allocated dynamically via MIK_AllocModuleSlot(). */
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.1",
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.1"
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.1"
98
+ "@mikrojs/registry": "0.18.2"
99
99
  },
100
100
  "engines": {
101
101
  "node": ">=24.0.0"
@@ -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']
@@ -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}
@@ -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
@@ -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
- bool updated = ApplyRunningConfig(have_response_config_ ? &response_config_ : nullptr,
943
- active_.options.trial_boots);
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
- if (have_response_config_ && response_config_.doc_cbor && response_config_.doc_cbor_len > 0) {
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
@@ -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
@@ -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 MIK_AllocModuleSlot(MIKRuntime* mik_rt) {
537
- int slot = mik_rt->next_module_slot++;
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
  }