@mikrojs/native 0.18.0 → 0.18.2-next.20260826235606

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.
Files changed (69) hide show
  1. package/CMakeLists.txt +62 -1
  2. package/dist/index.d.ts +17 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
  7. package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
  8. package/dist/runtime/result/native-result.node-shim.js +41 -0
  9. package/dist/runtime/result/native-result.node-shim.js.map +1 -0
  10. package/dist/runtime/result/types.d.ts +55 -0
  11. package/dist/runtime/result/types.d.ts.map +1 -0
  12. package/dist/runtime/result/types.js +2 -0
  13. package/dist/runtime/result/types.js.map +1 -0
  14. package/dist/runtime/schema/core.d.ts +115 -0
  15. package/dist/runtime/schema/core.d.ts.map +1 -0
  16. package/dist/runtime/schema/core.js +259 -0
  17. package/dist/runtime/schema/core.js.map +1 -0
  18. package/dist/runtime/schema/shared.d.ts +54 -0
  19. package/dist/runtime/schema/shared.d.ts.map +1 -0
  20. package/dist/runtime/schema/shared.js +489 -0
  21. package/dist/runtime/schema/shared.js.map +1 -0
  22. package/dist/types.d.ts +7 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/include/mikrojs/cbor_helpers.h +20 -0
  25. package/include/mikrojs/mem.h +11 -0
  26. package/include/mikrojs/mikrojs.h +13 -3
  27. package/include/mikrojs/ota_client.h +339 -0
  28. package/include/mikrojs/ota_config.h +151 -0
  29. package/include/mikrojs/ota_env.h +192 -0
  30. package/include/mikrojs/ota_js_hooks.h +71 -0
  31. package/include/mikrojs/ota_policy.h +131 -0
  32. package/include/mikrojs/ota_slots.h +47 -0
  33. package/include/mikrojs/private.h +2 -2
  34. package/include/mikrojs/sys_codec.h +61 -0
  35. package/package.json +7 -5
  36. package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
  37. package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
  38. package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
  39. package/runtime/internal.d.ts +25 -16
  40. package/runtime/kv/shared.ts +11 -5
  41. package/runtime/kv/types.ts +4 -4
  42. package/runtime/ota/client.ts +12 -51
  43. package/runtime/ota/config.ts +18 -0
  44. package/runtime/ota/ota.ts +34 -70
  45. package/runtime/ota/types.ts +303 -3
  46. package/runtime/schema/core.ts +539 -0
  47. package/runtime/schema/schema.ts +36 -314
  48. package/runtime/schema/shared.ts +494 -0
  49. package/runtime/schema/types.ts +84 -12
  50. package/scripts/bundle-runtime.js +33 -0
  51. package/scripts/gen-checkin-fixtures.js +323 -0
  52. package/src/builtins.cpp +7 -8
  53. package/src/fs.cpp +3 -0
  54. package/src/mem.cpp +38 -0
  55. package/src/mik_abort.cpp +8 -1
  56. package/src/mik_cbor.cpp +43 -5
  57. package/src/mik_inspect.cpp +128 -22
  58. package/src/mik_ota_client.cpp +1163 -0
  59. package/src/mik_ota_config.cpp +433 -0
  60. package/src/mik_ota_js_hooks.cpp +190 -0
  61. package/src/mik_ota_policy.cpp +427 -0
  62. package/src/mik_ota_slots.cpp +249 -0
  63. package/src/mik_repl.cpp +9 -3
  64. package/src/mik_result.cpp +3 -1
  65. package/src/mik_sys_codec.cpp +167 -0
  66. package/src/mikrojs.cpp +24 -2
  67. package/src/modules.cpp +32 -13
  68. package/runtime/ota/client-impl.ts +0 -590
  69. package/runtime/ota/policy.ts +0 -299
@@ -0,0 +1,433 @@
1
+ #include "mikrojs/ota_config.h"
2
+
3
+ #include <nanocbor/nanocbor.h>
4
+
5
+ #include <cstdio>
6
+ #include <cstdlib>
7
+ #include <cstring>
8
+ #include <string>
9
+ #include <vector>
10
+
11
+ #include "mikrojs/cbor_helpers.h"
12
+ #include "mikrojs/ota_slots.h"
13
+ #include "mikrojs/platform.h"
14
+
15
+ namespace mikrojs {
16
+
17
+ namespace {
18
+
19
+ /* Copy `source`'s own enumerable string keys onto `target`, TOP LEVEL ONLY.
20
+ * This is the `{...defaults, ...doc}` spread and nothing more: a deviating
21
+ * value inside a nested unit ships the whole unit, so recursing here would
22
+ * merge across schema branches that have no common meaning. Returns false with
23
+ * the exception left pending. */
24
+ bool CopyOwnProps(JSContext* ctx, JSValue target, JSValue source) {
25
+ if (!JS_IsObject(source)) return true;
26
+ JSPropertyEnum* props = nullptr;
27
+ uint32_t count = 0;
28
+ if (JS_GetOwnPropertyNames(ctx, &props, &count, source,
29
+ JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) != 0) {
30
+ return false;
31
+ }
32
+ bool ok = true;
33
+ for (uint32_t i = 0; i < count; i++) {
34
+ JSValue value = JS_GetProperty(ctx, source, props[i].atom);
35
+ if (JS_IsException(value)) {
36
+ ok = false;
37
+ break;
38
+ }
39
+ /* Consumes `value`; the atom stays owned by the enum. */
40
+ if (JS_DefinePropertyValue(ctx, target, props[i].atom, value, JS_PROP_C_W_E) < 0) {
41
+ ok = false;
42
+ break;
43
+ }
44
+ }
45
+ JS_FreePropertyEnum(ctx, props, count);
46
+ return ok;
47
+ }
48
+
49
+ /* A fresh object carrying `source`'s top-level properties. Every read hands out
50
+ * one of these rather than the cached defaults themselves: an app is free to
51
+ * mutate what it got, and a mutation reaching the cache would poison every
52
+ * later read for the runtime's lifetime. */
53
+ JSValue ShallowCopy(JSContext* ctx, JSValue source) {
54
+ JSValue out = JS_NewObject(ctx);
55
+ if (JS_IsException(out)) return out;
56
+ if (!CopyOwnProps(ctx, out, source)) {
57
+ JS_FreeValue(ctx, out);
58
+ return JS_EXCEPTION;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ } // namespace
64
+
65
+ MIKOtaConfigReader::~MIKOtaConfigReader() {
66
+ if (has_last_good_ && last_good_ctx_) JS_FreeValue(last_good_ctx_, last_good_);
67
+ if (has_defaults_ && defaults_ctx_) JS_FreeValue(defaults_ctx_, defaults_);
68
+ }
69
+
70
+ JSValue MIKOtaConfigReader::ServeLastGood(JSContext* ctx) const {
71
+ if (!has_last_good_) return JS_UNDEFINED;
72
+ return JS_DupValue(ctx, last_good_);
73
+ }
74
+
75
+ void MIKOtaConfigReader::KeepLastGood(JSContext* ctx, JSValue value) {
76
+ if (has_last_good_ && last_good_ctx_) JS_FreeValue(last_good_ctx_, last_good_);
77
+ last_good_ = JS_DupValue(ctx, value);
78
+ last_good_ctx_ = ctx;
79
+ has_last_good_ = true;
80
+ }
81
+
82
+ JSValue MIKOtaConfigReader::ServeFallback(JSContext* ctx) {
83
+ JSValue held = ServeLastGood(ctx);
84
+ if (!JS_IsUndefined(held)) return held;
85
+ /* Nothing has read successfully yet this runtime, so there is nothing to
86
+ * hold on to: the defaults are the honest answer, and the flap the held
87
+ * value exists to prevent cannot happen before the first success. */
88
+ JSValue defaults = Defaults(ctx);
89
+ if (JS_IsUndefined(defaults)) {
90
+ return JS_ThrowPlainError(
91
+ ctx, "ota.config(): no config to read: the stored document could not be read and "
92
+ "this build carries no readable app manifest to take defaults from");
93
+ }
94
+ // Not kept as the last good value: only a read the store answered records
95
+ // one, so a recovered store still replaces the defaults with the document.
96
+ return ShallowCopy(ctx, defaults);
97
+ }
98
+
99
+ JSValue MIKOtaConfigReader::Read(JSContext* ctx) {
100
+ bool store_failed = false;
101
+ JSValue doc = LoadDoc(ctx, &store_failed);
102
+ bool serving_doc = !JS_IsUndefined(doc);
103
+
104
+ if (!store_failed) {
105
+ bool rolled_back = false;
106
+ if (!Account(serving_doc, &rolled_back) && serving_doc) {
107
+ /* The trial store could not answer, so nothing was written and the
108
+ * whole block retries on the next read. Serving the document
109
+ * meanwhile would run the app on values whose crash budget could
110
+ * not be charged, which is the one thing the accounting is for. */
111
+ JS_FreeValue(ctx, doc);
112
+ return ServeFallback(ctx);
113
+ }
114
+ if (rolled_back) {
115
+ /* The rollback replaced the stored document under us, and this very
116
+ * read returning the restored values is what breaks a crash loop. */
117
+ JS_FreeValue(ctx, doc);
118
+ doc = LoadDoc(ctx, &store_failed);
119
+ }
120
+ }
121
+ if (store_failed) {
122
+ JS_FreeValue(ctx, doc);
123
+ return ServeFallback(ctx);
124
+ }
125
+
126
+ JSValue defaults = Defaults(ctx); /* borrowed */
127
+ JSValue result;
128
+ if (JS_IsUndefined(defaults)) {
129
+ if (JS_IsUndefined(doc)) {
130
+ return JS_ThrowPlainError(
131
+ ctx, "ota.config(): no config to read: this build carries no readable app "
132
+ "manifest, so it has no config defaults. Deploy it with `mikro deploy`, "
133
+ "or run `mikro dev`");
134
+ }
135
+ /* No defaults to spread under it: the document stands alone, and it is
136
+ * decoded fresh on every read so the caller may mutate it. */
137
+ result = doc;
138
+ } else if (JS_IsUndefined(doc)) {
139
+ result = ShallowCopy(ctx, defaults);
140
+ } else {
141
+ result = JS_NewObject(ctx);
142
+ if (!JS_IsException(result) &&
143
+ !(CopyOwnProps(ctx, result, defaults) && CopyOwnProps(ctx, result, doc))) {
144
+ JS_FreeValue(ctx, result);
145
+ result = JS_EXCEPTION;
146
+ }
147
+ JS_FreeValue(ctx, doc);
148
+ }
149
+ if (JS_IsException(result)) return result;
150
+
151
+ KeepLastGood(ctx, result);
152
+ return result;
153
+ }
154
+
155
+ bool MIKOtaConfigReader::Account(bool serving_doc, bool* out_rolled_back) {
156
+ *out_rolled_back = false;
157
+
158
+ if (accounted_) {
159
+ if (!serving_doc) return true;
160
+ MIKOtaConfigTrial trial = {};
161
+ if (mik__ota_load_trial(env_, &trial) == MIK_OTA_KV_OK && !trial.read) {
162
+ /* A document delivered mid-boot: the boot was already accounted, but
163
+ * this read is what makes the trial adoptable. */
164
+ MIKOtaConfigTrial next = {trial.left, true};
165
+ mik__ota_store_trial(env_, next);
166
+ }
167
+ return true;
168
+ }
169
+
170
+ MIKOtaConfigTrial trial = {};
171
+ MIKOtaKvStatus status = mik__ota_load_trial(env_, &trial);
172
+ // A read that never happened must leave the trial untouched and retry on
173
+ // the next read, or a starved store silently burns the budget.
174
+ if (status == MIK_OTA_KV_ERROR) return false;
175
+
176
+ // Only a document this read actually serves is charged. A boot that fell
177
+ // back to the defaults (nothing stored, stamped for another version, bytes
178
+ // that would not decode) never ran the app on the document, and recording
179
+ // that it did is the defect this condition exists to prevent.
180
+ if (status == MIK_OTA_KV_OK && serving_doc) {
181
+ if (trial.left <= 0) {
182
+ // Reads come before the writes, so a failure here leaves the
183
+ // trial untouched and the whole block retries on the next read.
184
+ MIKOtaLoadedConfig failed = mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT);
185
+ if (failed.failed) return false;
186
+ MIKOtaLoadedConfig previous = mik__ota_load_slot(env_, MIK_OTA_CFG_PREV);
187
+ if (previous.failed) return false;
188
+
189
+ if (previous.present) {
190
+ mik__ota_store_slot(env_, MIK_OTA_CFG_CURRENT, previous.cfg);
191
+ } else {
192
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_CURRENT);
193
+ }
194
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
195
+ mik__ota_clear_trial(env_);
196
+ *out_rolled_back = true;
197
+
198
+ if (failed.present && failed.cfg.rev[0]) {
199
+ MIKOtaConfigErrorReport report = {};
200
+ snprintf(report.rev, sizeof(report.rev), "%s", failed.cfg.rev);
201
+ snprintf(report.message, sizeof(report.message), "%s",
202
+ "rolled back: no completed check-in while the config was on trial");
203
+ mik__ota_store_config_error(env_, report);
204
+ }
205
+ } else {
206
+ // Burn one boot, and record that the app has read the document:
207
+ // adoption (in the client) waits for that, or an app reading
208
+ // config only at boot could have a never-executed document
209
+ // adopted under it, crash-looping at some later power cycle.
210
+ MIKOtaConfigTrial next = {trial.left - 1, true};
211
+ mik__ota_store_trial(env_, next);
212
+ }
213
+ }
214
+ // Only after the trial work fully succeeded: a retried read must never
215
+ // burn a second boot for the same power cycle. The boot has used its
216
+ // accounting slot even when there was no document to charge it against, so
217
+ // one delivered later in the same boot is marked read, not burned again.
218
+ accounted_ = true;
219
+ return true;
220
+ }
221
+
222
+ JSValue MIKOtaConfigReader::LoadDoc(JSContext* ctx, bool* out_failed) {
223
+ *out_failed = false;
224
+
225
+ MIKOtaLoadedConfig stored = mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT);
226
+ if (stored.failed) {
227
+ *out_failed = true;
228
+ return JS_UNDEFINED;
229
+ }
230
+ if (!stored.present || !stored.cfg.doc_cbor || stored.cfg.doc_cbor_len == 0) {
231
+ return JS_UNDEFINED;
232
+ }
233
+
234
+ char version[32] = {};
235
+ if (!env_ || !env_->read_app_version ||
236
+ !env_->read_app_version(env_->opaque, version, sizeof(version))) {
237
+ return JS_UNDEFINED;
238
+ }
239
+ // Stamped for another release: it was computed against that release's
240
+ // schema, so it says nothing about this one.
241
+ if (strcmp(stored.cfg.version, version) != 0) return JS_UNDEFINED;
242
+
243
+ // Decoded verbatim: the reader never walks into a value.
244
+ nanocbor_value_t it;
245
+ nanocbor_decoder_init(&it, stored.cfg.doc_cbor, stored.cfg.doc_cbor_len);
246
+ JSValue doc = mik__cbor_decode_value(ctx, &it, 0);
247
+ if (JS_IsException(doc)) {
248
+ // Bytes that will not decode are no more readable than none at all, and
249
+ // the defaults are a working config. Nothing is rewritten: the rev the
250
+ // device echoes is unchanged, so the registry re-serves the document.
251
+ JS_FreeValue(ctx, JS_GetException(ctx));
252
+ return JS_UNDEFINED;
253
+ }
254
+ return doc;
255
+ }
256
+
257
+ JSValue MIKOtaConfigReader::Defaults(JSContext* ctx) {
258
+ if (has_defaults_) return defaults_;
259
+
260
+ char* manifest = (env_ && env_->read_manifest) ? env_->read_manifest(env_->opaque) : nullptr;
261
+ if (!manifest) return JS_UNDEFINED;
262
+ JSValue parsed = JS_ParseJSON(ctx, manifest, strlen(manifest), "mikro.app.json");
263
+ free(manifest);
264
+ if (JS_IsException(parsed)) {
265
+ JS_FreeValue(ctx, JS_GetException(ctx));
266
+ return JS_UNDEFINED;
267
+ }
268
+
269
+ JSValue value = JS_GetPropertyStr(ctx, parsed, "configDefaults");
270
+ if (!JS_IsObject(value)) {
271
+ JS_FreeValue(ctx, value);
272
+ value = JS_UNDEFINED;
273
+ }
274
+ JS_FreeValue(ctx, parsed);
275
+ if (JS_HasException(ctx)) JS_FreeValue(ctx, JS_GetException(ctx));
276
+
277
+ if (JS_IsUndefined(value)) {
278
+ // A manifest without materialized defaults: the app declares no config
279
+ // schema, so an empty object is its config, not a failure. Pack writes
280
+ // `configDefaults` (possibly {}) whenever a schema is declared.
281
+ value = JS_NewObject(ctx);
282
+ if (JS_IsException(value)) {
283
+ JS_FreeValue(ctx, JS_GetException(ctx));
284
+ return JS_UNDEFINED;
285
+ }
286
+ }
287
+
288
+ // Cached on success only. A manifest that could not be read or parsed under
289
+ // heap pressure is retried on the next call rather than remembered as "this
290
+ // build has no defaults".
291
+ defaults_ = value;
292
+ defaults_ctx_ = ctx;
293
+ has_defaults_ = true;
294
+ return defaults_;
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
+
433
+ } // namespace mikrojs
@@ -0,0 +1,190 @@
1
+ #include "mikrojs/ota_js_hooks.h"
2
+
3
+ #include <map>
4
+
5
+ #include "mikrojs/utils.h"
6
+
7
+ namespace mikrojs {
8
+
9
+ namespace {
10
+
11
+ /* The hooks instance rides to the promise callbacks as a pair of integers in
12
+ * func_data, which carries JSValues and not pointers.
13
+ *
14
+ * A JS class with an opaque would be the tidier vehicle, but class ids are
15
+ * per-runtime and this unit has no per-runtime init to register one in — doing
16
+ * it lazily inside a call aborts the second runtime in a process. Two ints have
17
+ * no lifecycle at all.
18
+ *
19
+ * What travels is an id and not the address: a pending beforeCheck outlives the
20
+ * hooks object whenever watch() is called a second time or the client is torn
21
+ * down mid-round, and the allocator is free to hand the same address to the
22
+ * replacement. An id is never reused, so a continuation from a destroyed
23
+ * instance finds nothing rather than settling its successor. */
24
+ uint64_t next_hook_id() {
25
+ static uint64_t counter = 0;
26
+ return ++counter;
27
+ }
28
+
29
+ std::map<uint64_t, MIKOtaJsHooks*>& live_hooks() {
30
+ static std::map<uint64_t, MIKOtaJsHooks*> hooks;
31
+ return hooks;
32
+ }
33
+
34
+ void put_self(JSContext* ctx, uint64_t id, JSValue out[2]) {
35
+ out[0] = JS_NewUint32(ctx, static_cast<uint32_t>(id & 0xffffffffu));
36
+ out[1] = JS_NewUint32(ctx, static_cast<uint32_t>((id >> 32) & 0xffffffffu));
37
+ }
38
+
39
+ MIKOtaJsHooks* take_self(JSContext* ctx, JSValue* func_data) {
40
+ if (!func_data) return nullptr;
41
+ uint32_t low = 0;
42
+ uint32_t high = 0;
43
+ if (JS_ToUint32(ctx, &low, func_data[0]) < 0) return nullptr;
44
+ if (JS_ToUint32(ctx, &high, func_data[1]) < 0) return nullptr;
45
+ uint64_t id = (static_cast<uint64_t>(high) << 32) | low;
46
+ auto it = live_hooks().find(id);
47
+ return it == live_hooks().end() ? nullptr : it->second;
48
+ }
49
+
50
+ /* A Result is an object carrying a boolean `ok`. Told apart from a bare
51
+ * teardown, which is a function, and from nothing at all. */
52
+ bool is_result(JSContext* ctx, JSValueConst value) {
53
+ if (!JS_IsObject(value) || JS_IsFunction(ctx, value)) return false;
54
+ JSValue ok = JS_GetPropertyStr(ctx, value, "ok");
55
+ bool looks_like = JS_IsBool(ok);
56
+ JS_FreeValue(ctx, ok);
57
+ return looks_like;
58
+ }
59
+
60
+ } // namespace
61
+
62
+ MIKOtaJsHooks::MIKOtaJsHooks(JSContext* ctx, JSValue before_check)
63
+ : ctx_(ctx), before_check_(before_check), id_(next_hook_id()) {
64
+ live_hooks()[id_] = this;
65
+ }
66
+
67
+ MIKOtaJsHooks::~MIKOtaJsHooks() {
68
+ live_hooks().erase(id_);
69
+ JS_FreeValue(ctx_, before_check_);
70
+ JS_FreeValue(ctx_, teardown_);
71
+ }
72
+
73
+ bool MIKOtaJsHooks::has_teardown() const { return JS_IsFunction(ctx_, teardown_); }
74
+
75
+ bool MIKOtaJsHooks::BeginBeforeCheck() {
76
+ if (!JS_IsFunction(ctx_, before_check_)) return false;
77
+ awaiting_before_check_ = true;
78
+ state_ = MIKOtaHookState::kPending;
79
+ JS_FreeValue(ctx_, teardown_);
80
+ teardown_ = JS_UNDEFINED;
81
+ Call(before_check_);
82
+ return true;
83
+ }
84
+
85
+ MIKOtaHookState MIKOtaJsHooks::PollBeforeCheck() { return state_; }
86
+
87
+ bool MIKOtaJsHooks::BeginTeardown() {
88
+ if (!JS_IsFunction(ctx_, teardown_)) return false;
89
+ awaiting_before_check_ = false;
90
+ state_ = MIKOtaHookState::kPending;
91
+ /* Held across the call: settling clears it, and the call needs it alive. */
92
+ JSValue teardown = JS_DupValue(ctx_, teardown_);
93
+ Call(teardown);
94
+ JS_FreeValue(ctx_, teardown);
95
+ return true;
96
+ }
97
+
98
+ MIKOtaHookState MIKOtaJsHooks::PollTeardown() { return state_; }
99
+
100
+ void MIKOtaJsHooks::Call(JSValue fn) {
101
+ JSValue result = JS_Call(ctx_, fn, JS_UNDEFINED, 0, nullptr);
102
+ if (JS_IsException(result)) {
103
+ /* A hook that threw is a hook that failed. Report it on serial rather
104
+ * than leaving the exception on the context for something unrelated to
105
+ * trip over later. */
106
+ mik_dump_error(ctx_);
107
+ JS_FreeValue(ctx_, result);
108
+ state_ = MIKOtaHookState::kFailed;
109
+ return;
110
+ }
111
+
112
+ /* A hook may return nothing at all, and reading a property off undefined
113
+ * throws rather than answering — so ask what it is before asking anything
114
+ * of it. */
115
+ JSValue then = JS_IsObject(result) ? JS_GetPropertyStr(ctx_, result, "then") : JS_UNDEFINED;
116
+ if (!JS_IsFunction(ctx_, then)) {
117
+ JS_FreeValue(ctx_, then);
118
+ Settle(result);
119
+ JS_FreeValue(ctx_, result);
120
+ return;
121
+ }
122
+
123
+ JSValue data[2];
124
+ put_self(ctx_, id_, data);
125
+ JSValue on_ok = JS_NewCFunctionData(ctx_, OnFulfilled, 1, /*magic=*/1, 2, data);
126
+ JSValue on_err = JS_NewCFunctionData(ctx_, OnFulfilled, 1, /*magic=*/0, 2, data);
127
+ JS_FreeValue(ctx_, data[0]);
128
+ JS_FreeValue(ctx_, data[1]);
129
+
130
+ JSValue args[2] = {on_ok, on_err};
131
+ JSValue chained = JS_Call(ctx_, then, result, 2, args);
132
+ if (JS_IsException(chained)) {
133
+ JS_FreeValue(ctx_, JS_GetException(ctx_));
134
+ state_ = MIKOtaHookState::kFailed;
135
+ }
136
+ JS_FreeValue(ctx_, chained);
137
+ JS_FreeValue(ctx_, on_ok);
138
+ JS_FreeValue(ctx_, on_err);
139
+ JS_FreeValue(ctx_, then);
140
+ JS_FreeValue(ctx_, result);
141
+ }
142
+
143
+ void MIKOtaJsHooks::Settle(JSValue value) {
144
+ if (!awaiting_before_check_) {
145
+ /* Whatever a teardown returns is ignored: it has nothing left to say. */
146
+ state_ = MIKOtaHookState::kOk;
147
+ return;
148
+ }
149
+
150
+ /* A bare function is the teardown, with no Result to unwrap. */
151
+ if (JS_IsFunction(ctx_, value)) {
152
+ JS_FreeValue(ctx_, teardown_);
153
+ teardown_ = JS_DupValue(ctx_, value);
154
+ state_ = MIKOtaHookState::kOk;
155
+ return;
156
+ }
157
+
158
+ if (is_result(ctx_, value)) {
159
+ JSValue ok = JS_GetPropertyStr(ctx_, value, "ok");
160
+ bool succeeded = JS_ToBool(ctx_, ok);
161
+ JS_FreeValue(ctx_, ok);
162
+ if (!succeeded) {
163
+ state_ = MIKOtaHookState::kFailed;
164
+ return;
165
+ }
166
+ JS_FreeValue(ctx_, teardown_);
167
+ teardown_ = JS_GetPropertyStr(ctx_, value, "value");
168
+ state_ = MIKOtaHookState::kOk;
169
+ return;
170
+ }
171
+
172
+ /* Nothing, or something that is neither: the round runs, with no teardown. */
173
+ state_ = MIKOtaHookState::kOk;
174
+ }
175
+
176
+ JSValue MIKOtaJsHooks::OnFulfilled(JSContext* ctx, JSValueConst this_val, int argc,
177
+ JSValueConst* argv, int magic, JSValue* func_data) {
178
+ (void)this_val;
179
+ MIKOtaJsHooks* hooks = take_self(ctx, func_data);
180
+ if (!hooks) return JS_UNDEFINED;
181
+ if (magic == 0) {
182
+ /* Rejected: a failed hook, whichever half was running. */
183
+ hooks->state_ = MIKOtaHookState::kFailed;
184
+ return JS_UNDEFINED;
185
+ }
186
+ hooks->Settle(argc > 0 ? argv[0] : JS_UNDEFINED);
187
+ return JS_UNDEFINED;
188
+ }
189
+
190
+ } // namespace mikrojs