@mikrojs/native 0.6.1 → 0.7.0

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.
@@ -7,20 +7,19 @@ export class PanicError extends Error {
7
7
  }
8
8
  }
9
9
 
10
- export function defineError<D extends Record<string, (...args: any[]) => Record<string, unknown>>>(
11
- _name: string,
12
- variants: D,
13
- ): {[K in keyof D & string]: (...args: Parameters<D[K]>) => {name: K} & ReturnType<D[K]>} {
14
- const constructors: Record<string, (...args: unknown[]) => Record<string, unknown>> = {}
15
- for (const key in variants) {
16
- const factory = variants[key]!
17
- constructors[key] = (...args: unknown[]) => {
18
- const fields = (factory as (...a: unknown[]) => Record<string, unknown>)(...args)
19
- fields.name = key
20
- return fields
21
- }
22
- }
23
- return constructors as unknown as {
24
- [K in keyof D & string]: (...args: Parameters<D[K]>) => {name: K} & ReturnType<D[K]>
10
+ export function matchError<E extends {name: string}, R>(
11
+ error: E,
12
+ handlers: {[K in E['name']]: (error: Extract<E, {name: K}>) => R},
13
+ ): R {
14
+ // hasOwn guard: bare `handlers[error.name]` would resolve to Object.prototype
15
+ // methods (toString, hasOwnProperty, ) for any error.name string that
16
+ // happens to match a prototype member — silently returning a wrong value
17
+ // instead of failing loudly. The type system enforces `name` is one of the
18
+ // union's tags, but interop with native or hand-built error objects can
19
+ // bypass that.
20
+ const h = handlers as unknown as Record<string, (e: E) => R>
21
+ if (!Object.hasOwn(h, error.name)) {
22
+ throw new TypeError(`matchError: no handler for ${error.name}`)
25
23
  }
24
+ return h[error.name]!(error)
26
25
  }
@@ -37,20 +37,10 @@ export declare function ok<T>(value: T): OkResult<T>
37
37
 
38
38
  export declare function err<E>(error: E): ErrResult<E>
39
39
 
40
- type VariantDef = Record<string, (...args: any[]) => Record<string, unknown>>
41
-
42
- type ErrorConstructors<D extends VariantDef> = {
43
- [K in keyof D & string]: (...args: Parameters<D[K]>) => {name: K} & ReturnType<D[K]>
44
- }
45
-
46
- export type ErrorOf<T extends Record<string, (...args: any[]) => any>> = {
47
- [K in keyof T & string]: ReturnType<T[K]>
48
- }[keyof T & string]
49
-
50
- export declare function defineError<D extends VariantDef>(
51
- name: string,
52
- variants: D,
53
- ): ErrorConstructors<D>
40
+ export declare function matchError<E extends {name: string}, R>(
41
+ error: E,
42
+ handlers: {[K in E['name']]: (error: Extract<E, {name: K}>) => R},
43
+ ): R
54
44
 
55
45
  /** Raw error shape returned by native mik__result_err(). */
56
46
  export interface NativeError {
@@ -1,10 +1,11 @@
1
- import {defineError, err, ok} from 'mikrojs/result'
1
+ import {err, ok} from 'mikrojs/result'
2
2
 
3
3
  import type {Result} from '../result/types.js'
4
4
 
5
- export const SchemaError = defineError('SchemaError', {
6
- ValidationFailed: (message: string, path: string) => ({message, path}),
7
- })
5
+ export const SchemaError = {
6
+ ValidationFailed: (message: string, path: string) =>
7
+ ({name: 'ValidationFailed', message, path}) as const,
8
+ }
8
9
  export type SchemaError = ReturnType<typeof SchemaError.ValidationFailed>
9
10
 
10
11
  // ── Schema types ────────────────────────────────────────────────────
@@ -1,24 +1,37 @@
1
1
  /**
2
2
  * Minimal composable stream primitives for mikrojs.
3
3
  *
4
- * Streams are plain `AsyncIterable<T>`no class hierarchy, no
5
- * lockable readers, no BYOB, no queueing strategies. Transforms are
6
- * async generator functions. Composition is function call.
4
+ * Streams are `AsyncIterable<Result<T, E>>` Result-yielding rather
5
+ * than throwing so error handling composes uniformly with the rest
6
+ * of the runtime's Result-based APIs. Combinators short-circuit on the
7
+ * first err item: they yield the err and return.
7
8
  *
8
- * This is deliberately a subset of what Snell's "new-streams" proposal
9
- * covers, adapted for the realities of QuickJS on a microcontroller:
10
- * no fast-path promise optimizations, no sendfile-style native
11
- * shortcuts, tight heap budget.
9
+ * Combinators that originate their own errors (`withTimeout`,
10
+ * `collectUntil`) widen the result's error type with a `StreamError`
11
+ * variant defined in `./types.ts`.
12
12
  *
13
13
  * V1 scope: enough to rewrite a modem AT channel as a one-file shim.
14
14
  * Expand only when a second call site asks for it.
15
15
  */
16
16
 
17
+ import {err, ok} from 'mikrojs/result'
18
+
19
+ import type {Result} from '../result/types.js'
20
+
17
21
  /** Decode a stream of UTF-8 byte chunks into a stream of strings. */
18
- export async function* decodeUtf8(source: AsyncIterable<Uint8Array>): AsyncIterable<string> {
22
+ export async function* decodeUtf8<E>(
23
+ source: AsyncIterable<Result<Uint8Array, E>>,
24
+ ): AsyncIterable<Result<string, E>> {
19
25
  const decoder = new TextDecoder()
20
- for await (const chunk of source) yield decoder.decode(chunk, {stream: true})
21
- yield decoder.decode()
26
+ for await (const r of source) {
27
+ if (!r.ok) {
28
+ yield r
29
+ return
30
+ }
31
+ yield ok(decoder.decode(r.value, {stream: true}))
32
+ }
33
+ const final = decoder.decode()
34
+ if (final.length > 0) yield ok(final)
22
35
  }
23
36
 
24
37
  /**
@@ -36,10 +49,10 @@ export async function* decodeUtf8(source: AsyncIterable<Uint8Array>): AsyncItera
36
49
  * for protocols that use a blank line as a separator, e.g. HTTP
37
50
  * headers vs. body.
38
51
  */
39
- export async function* splitLines(
40
- source: AsyncIterable<string>,
52
+ export async function* splitLines<E>(
53
+ source: AsyncIterable<Result<string, E>>,
41
54
  delimiter: string = '\n',
42
- ): AsyncIterable<string> {
55
+ ): AsyncIterable<Result<string, E>> {
43
56
  // indexOf('') returns 0 unconditionally, which would make the inner
44
57
  // loop spin forever yielding empty strings. Reject at entry instead
45
58
  // of hanging the event loop on a user mistake.
@@ -47,16 +60,20 @@ export async function* splitLines(
47
60
  throw new RangeError('splitLines: delimiter must be non-empty')
48
61
  }
49
62
  let buffer = ''
50
- for await (const chunk of source) {
51
- buffer += chunk
63
+ for await (const r of source) {
64
+ if (!r.ok) {
65
+ yield r
66
+ return
67
+ }
68
+ buffer += r.value
52
69
  let idx = buffer.indexOf(delimiter)
53
70
  while (idx !== -1) {
54
- yield buffer.slice(0, idx)
71
+ yield ok(buffer.slice(0, idx))
55
72
  buffer = buffer.slice(idx + delimiter.length)
56
73
  idx = buffer.indexOf(delimiter)
57
74
  }
58
75
  }
59
- if (buffer.length > 0) yield buffer
76
+ if (buffer.length > 0) yield ok(buffer)
60
77
  }
61
78
 
62
79
  /**
@@ -68,34 +85,30 @@ export async function* splitLines(
68
85
  * like modem AT commands where the terminator (`OK`/`ERROR`) is
69
86
  * semantically different from the response body lines in between.
70
87
  *
71
- * If the stream closes before any item matches, throws `StreamClosed`
72
- * (a plain Error with a `.name`). Callers should wrap this in their
73
- * own Result type if they want structured error handling.
88
+ * If the stream closes before any item matches, returns
89
+ * `err({name: 'StreamClosed'})`. Source errors propagate unchanged.
74
90
  */
75
- export async function collectUntil<T>(
76
- source: AsyncIterable<T>,
91
+ export async function collectUntil<T, E>(
92
+ source: AsyncIterable<Result<T, E>>,
77
93
  predicate: (item: T) => boolean,
78
- ): Promise<{matched: T; collected: T[]}> {
94
+ ): Promise<Result<{matched: T; collected: T[]}, E | {name: 'StreamClosed'}>> {
79
95
  const collected: T[] = []
80
- for await (const item of source) {
81
- if (predicate(item)) {
82
- return {matched: item, collected}
83
- }
84
- collected.push(item)
96
+ for await (const r of source) {
97
+ if (!r.ok) return r
98
+ if (predicate(r.value)) return ok({matched: r.value, collected})
99
+ collected.push(r.value)
85
100
  }
86
- const err = new Error('Stream closed before predicate matched')
87
- err.name = 'StreamClosed'
88
- throw err
101
+ return err({name: 'StreamClosed' as const})
89
102
  }
90
103
 
91
104
  /**
92
105
  * Wrap an async iterable with a total-duration timeout.
93
106
  *
94
107
  * The deadline starts when this function is called (not per-item).
95
- * If the next item doesn't arrive before the deadline, the returned
96
- * iterable throws a `TimeoutError`, and cleans up the upstream source
97
- * by calling its `return()` method. Callers using `for await` will see
98
- * the error propagate out of the loop.
108
+ * If the next item doesn't arrive before the deadline, yields
109
+ * `err({name: 'Timeout', ms})` and ends, cleaning up the upstream
110
+ * source by calling its `return()` method. Source errors propagate
111
+ * unchanged.
99
112
  *
100
113
  * Implementation notes:
101
114
  * - Promise.race with setTimeout would leak orphaned timers on the
@@ -105,29 +118,39 @@ export async function collectUntil<T>(
105
118
  * - The upstream iterator is explicitly .return()'d on exit (normal,
106
119
  * timeout, or consumer break) so underlying resources (UART claims,
107
120
  * socket handles, ring buffers) get released.
121
+ *
122
+ * Source contract: the source iterator must surface failures via
123
+ * `yield err(...)` items, not by rejecting `next()`. On a timer win,
124
+ * the in-flight `next()` promise is discarded; if that promise
125
+ * rejects, the rejection is unhandled. Every Result-yielding source
126
+ * in this codebase (`uart.read()`, `fs.readStream`, `Response.body`)
127
+ * satisfies that contract — only relevant if you compose
128
+ * `withTimeout` over a hand-rolled iterator.
108
129
  */
109
- export async function* withTimeout<T>(source: AsyncIterable<T>, ms: number): AsyncIterable<T> {
130
+ export async function* withTimeout<T, E>(
131
+ source: AsyncIterable<Result<T, E>>,
132
+ ms: number,
133
+ ): AsyncIterable<Result<T, E | {name: 'Timeout'; ms: number}>> {
110
134
  const deadline = Date.now() + ms
111
135
  const iter = source[Symbol.asyncIterator]()
112
136
  try {
113
137
  while (true) {
114
138
  const remaining = deadline - Date.now()
115
139
  if (remaining <= 0) {
116
- const err = new Error(`Stream did not complete within ${ms}ms`)
117
- err.name = 'TimeoutError'
118
- throw err
140
+ yield err({name: 'Timeout' as const, ms})
141
+ return
119
142
  }
120
143
 
121
144
  let timerId: ReturnType<typeof setTimeout> | undefined
122
- let result: IteratorResult<T, unknown>
145
+ let result: IteratorResult<Result<T, E>, unknown>
146
+ let timedOut = false
123
147
  try {
124
148
  result = await Promise.race([
125
149
  iter.next(),
126
- new Promise<IteratorResult<T, unknown>>((_, reject) => {
150
+ new Promise<IteratorResult<Result<T, E>, unknown>>((resolve) => {
127
151
  timerId = setTimeout(() => {
128
- const err = new Error(`Stream did not complete within ${ms}ms`)
129
- err.name = 'TimeoutError'
130
- reject(err)
152
+ timedOut = true
153
+ resolve({done: true, value: undefined})
131
154
  }, remaining)
132
155
  }),
133
156
  ])
@@ -135,8 +158,13 @@ export async function* withTimeout<T>(source: AsyncIterable<T>, ms: number): Asy
135
158
  if (timerId !== undefined) clearTimeout(timerId)
136
159
  }
137
160
 
161
+ if (timedOut) {
162
+ yield err({name: 'Timeout' as const, ms})
163
+ return
164
+ }
138
165
  if (result.done) return
139
- yield result.value as T
166
+ yield result.value
167
+ if (!result.value.ok) return
140
168
  }
141
169
  } finally {
142
170
  if (typeof iter.return === 'function') {
@@ -5,42 +5,62 @@
5
5
  * bytecode at firmware build time. This file is the declaration-only
6
6
  * surface consumed by the `mikrojs/stream` re-export in
7
7
  * `packages/mikrojs` and by user apps via their tsconfig.
8
+ *
9
+ * Streaming sources yield `Result<T, E>` rather than throwing — see
10
+ * the design note in `stream.ts`. Combinators that originate their own
11
+ * errors widen the error type with a `StreamError` variant.
8
12
  */
9
13
 
14
+ import type {Result} from 'mikrojs/result'
15
+
16
+ export type StreamError = {name: 'Timeout'; ms: number} | {name: 'StreamClosed'}
17
+
10
18
  /** Decode a stream of UTF-8 byte chunks into a stream of strings. */
11
- export declare function decodeUtf8(source: AsyncIterable<Uint8Array>): AsyncIterable<string>
19
+ export declare function decodeUtf8<E>(
20
+ source: AsyncIterable<Result<Uint8Array, E>>,
21
+ ): AsyncIterable<Result<string, E>>
12
22
 
13
23
  /**
14
24
  * Split a stream of text into a stream of lines on the given delimiter
15
25
  * (default `\n`). Partial lines spanning chunk boundaries are buffered
16
26
  * and reassembled. Empty lines between consecutive delimiters are
17
- * preserved. Throws `RangeError` if called with an empty delimiter.
27
+ * preserved. Throws `RangeError` if called with an empty delimiter
28
+ * (programmer error, not a stream error).
18
29
  */
19
- export declare function splitLines(
20
- source: AsyncIterable<string>,
30
+ export declare function splitLines<E>(
31
+ source: AsyncIterable<Result<string, E>>,
21
32
  delimiter?: string,
22
- ): AsyncIterable<string>
33
+ ): AsyncIterable<Result<string, E>>
23
34
 
24
35
  /**
25
36
  * Consume a stream until an item matches `predicate`, then stop.
26
37
  *
27
- * Returns `{matched, collected}` where `collected` is every item that
28
- * came BEFORE the match (not including the matching item). Throws an
29
- * Error with `name === 'StreamClosed'` if the stream ends before any
30
- * item matches.
38
+ * Returns `ok({matched, collected})` where `collected` is every item
39
+ * that came BEFORE the match (not including the matching item).
40
+ * Returns `err({name: 'StreamClosed'})` if the stream ends before any
41
+ * item matches. Propagates the source's error variants unchanged.
31
42
  */
32
- export declare function collectUntil<T>(
33
- source: AsyncIterable<T>,
43
+ export declare function collectUntil<T, E>(
44
+ source: AsyncIterable<Result<T, E>>,
34
45
  predicate: (item: T) => boolean,
35
- ): Promise<{matched: T; collected: T[]}>
46
+ ): Promise<Result<{matched: T; collected: T[]}, E | Extract<StreamError, {name: 'StreamClosed'}>>>
36
47
 
37
48
  /**
38
- * Wrap an async iterable with a total-duration deadline. Throws an
39
- * Error with `name === 'TimeoutError'` if the next item doesn't
40
- * arrive in time. Releases the upstream iterator via `return()` on
41
- * timeout, error, or consumer break.
49
+ * Wrap an async iterable with a total-duration deadline. Yields
50
+ * `err({name: 'Timeout', ms})` if the next item doesn't arrive in
51
+ * time, then ends. Releases the upstream iterator via `return()` on
52
+ * timeout, source error, or consumer break.
53
+ *
54
+ * Source contract: the source must surface failures via `yield err(...)`,
55
+ * not by rejecting `next()`. On a timer win the in-flight `next()`
56
+ * promise is discarded — if it later rejects, the rejection is
57
+ * unhandled. All Result-yielding sources in this runtime
58
+ * (`uart.read()`, `fs.readStream`, `Response.body`) satisfy that.
42
59
  */
43
- export declare function withTimeout<T>(source: AsyncIterable<T>, ms: number): AsyncIterable<T>
60
+ export declare function withTimeout<T, E>(
61
+ source: AsyncIterable<Result<T, E>>,
62
+ ms: number,
63
+ ): AsyncIterable<Result<T, E | Extract<StreamError, {name: 'Timeout'}>>>
44
64
 
45
65
  /* BufferedReader lives in the sibling `mikrojs/reader` module — see
46
66
  * `runtime/reader/` — so apps that only need byte-level framing can
@@ -53,7 +53,13 @@ export interface UartTx {
53
53
  * @public
54
54
  */
55
55
  export interface UartRx {
56
- read(): Result<AsyncIterable<Uint8Array>, UartError>
56
+ /**
57
+ * Open a Result-yielding async iterable of received chunks. Mid-stream
58
+ * failures (driver fault, port closed mid-iteration) arrive as a single
59
+ * terminal `err(UartError)` item rather than throwing — composes with
60
+ * stream/* combinators and other Result-based APIs.
61
+ */
62
+ read(): Result<AsyncIterable<Result<Uint8Array, UartError>>, UartError>
57
63
  }
58
64
 
59
65
  /**
@@ -36,14 +36,15 @@ export class Uart implements UartBase {
36
36
  return this.#native.write(data)
37
37
  }
38
38
 
39
- read(): Result<AsyncIterable<Uint8Array>, UartError> {
39
+ read(): Result<AsyncIterable<Result<Uint8Array, UartError>>, UartError> {
40
40
  const result = this.#native.read()
41
41
  if (!result.ok) return result
42
42
  const nativeIter = result.value
43
- // Wrap the native iterator object (which has next/return) into a proper AsyncIterable
44
- const iterable: AsyncIterable<Uint8Array> = {
43
+ // Wrap the native iterator object (which has next/return) into a proper AsyncIterable.
44
+ // Native yields Result items directly via mik__result_ok / mik__result_err_tag.
45
+ const iterable: AsyncIterable<Result<Uint8Array, UartError>> = {
45
46
  [Symbol.asyncIterator]() {
46
- return nativeIter as AsyncIterator<Uint8Array>
47
+ return nativeIter as AsyncIterator<Result<Uint8Array, UartError>>
47
48
  },
48
49
  }
49
50
  return ok(iterable)
@@ -12,30 +12,20 @@
12
12
  static const char* TAG = "mik_app_config";
13
13
 
14
14
  void MIK_DefaultConfig(MIKConfig* config) {
15
- config->restart_on_uncaught_exception = false;
16
- config->restart_delay_ms = 1000;
15
+ config->panic_restart_delay_ms = 1000;
17
16
  config->stack_size = 0;
18
17
  config->mem_reserved = 64 * 1024;
19
18
  config->fs_read_max = 0; /* 0 = runtime default (65536) */
20
19
  config->entry_point[0] = '\0';
21
20
  config->wifi_country[0] = '\0';
22
21
  config->wifi_hostname[0] = '\0';
22
+ config->log_dir[0] = '\0';
23
+ config->log_max_size = 64 * 1024;
24
+ config->log_flush = MIK_LOG_FLUSH_ERROR;
23
25
  }
24
26
 
25
27
  /* Minimal JSON parser for config file — avoids cJSON dependency.
26
- * Only handles the flat object with bool/number fields we need. */
27
- static bool mik__json_get_bool(const char* json, const char* key, bool* out) {
28
- char pattern[128];
29
- snprintf(pattern, sizeof(pattern), "\"%s\"", key);
30
- const char* p = strstr(json, pattern);
31
- if (!p) return false;
32
- p += strlen(pattern);
33
- while (*p == ' ' || *p == '\t' || *p == ':') p++;
34
- if (strncmp(p, "true", 4) == 0) { *out = true; return true; }
35
- if (strncmp(p, "false", 5) == 0) { *out = false; return true; }
36
- return false;
37
- }
38
-
28
+ * Only handles the flat object with string/number fields we need. */
39
29
  static bool mik__json_get_string(const char* json, const char* key, char* out, size_t out_size) {
40
30
  char pattern[128];
41
31
  snprintf(pattern, sizeof(pattern), "\"%s\"", key);
@@ -326,14 +316,10 @@ int MIK_LoadConfig(const char* base_path, MIKConfig* config) {
326
316
  snprintf(path, sizeof(path), "%s/mikro.config.json", app_dir);
327
317
  buf = mik__read_json_file(path, &st);
328
318
  if (buf) {
329
- bool bool_val;
330
319
  double num_val;
331
320
 
332
- if (mik__json_get_bool(buf, "restartOnUncaughtException", &bool_val)) {
333
- config->restart_on_uncaught_exception = bool_val;
334
- }
335
- if (mik__json_get_number(buf, "restartDelay", &num_val)) {
336
- config->restart_delay_ms = (int)num_val;
321
+ if (mik__json_get_number(buf, "panicRestartDelay", &num_val)) {
322
+ config->panic_restart_delay_ms = (int)num_val;
337
323
  }
338
324
  if (mik__json_get_number(buf, "stackSize", &num_val)) {
339
325
  config->stack_size = (size_t)num_val;
@@ -349,9 +335,19 @@ int MIK_LoadConfig(const char* base_path, MIKConfig* config) {
349
335
  mik__json_get_string(buf, "wifi.hostname", config->wifi_hostname,
350
336
  sizeof(config->wifi_hostname));
351
337
 
338
+ mik__json_get_string(buf, "logFile.dir", config->log_dir, sizeof(config->log_dir));
339
+ if (mik__json_get_number(buf, "logFile.maxSize", &num_val)) {
340
+ config->log_max_size = (uint32_t)num_val;
341
+ }
342
+ char flush_str[16];
343
+ if (mik__json_get_string(buf, "logFile.flush", flush_str, sizeof(flush_str))) {
344
+ config->log_flush =
345
+ strcmp(flush_str, "line") == 0 ? MIK_LOG_FLUSH_LINE : MIK_LOG_FLUSH_ERROR;
346
+ }
347
+
352
348
  platform->log(MIK_LOG_INFO, TAG,
353
- "Loaded config: restart=%d delay=%dms stack=%u reserved=%lu",
354
- config->restart_on_uncaught_exception, config->restart_delay_ms,
349
+ "Loaded config: delay=%dms stack=%u reserved=%lu",
350
+ config->panic_restart_delay_ms,
355
351
  (unsigned)config->stack_size, (unsigned long)config->mem_reserved);
356
352
  free(buf);
357
353
  }
@@ -420,6 +420,7 @@ void mik__console_init(JSContext* ctx, JSValue global_obj) {
420
420
  * bindings they'll never use. The mikrojs/test built-in has a console.log
421
421
  * fallback for when these globals aren't present. */
422
422
  void MIK_EnableTestHelpers(MIKRuntime* mik_rt) {
423
+ mik_rt->test_mode = true;
423
424
  JSContext* ctx = mik_rt->ctx;
424
425
  JSValue global_obj = JS_GetGlobalObject(ctx);
425
426
  JS_SetPropertyStr(ctx, global_obj, "__testEmit",
package/src/mik_repl.cpp CHANGED
@@ -47,6 +47,23 @@ bool mik__repl_is_protocol_mode(void) {
47
47
 
48
48
  /* ── TLV frame helpers ───────────────────────────────────────────── */
49
49
 
50
+ /* Plain bool, not atomic — relies on the assumption that mik__proto_send
51
+ * is only ever called from the REPL serve task, which is the same task
52
+ * that drives mik__console_write (since console_write is invoked by the
53
+ * runtime's stdout/stderr hooks during JS execution, and JS runs on the
54
+ * serve task). If a future feature introduces another task that calls
55
+ * mik__console_write directly (e.g. a watchdog or a sensor task that
56
+ * writes to console), its writes during a proto_send window will be
57
+ * dropped by the file-log tap. Revisit (task-local storage, or move the
58
+ * suppression into mik__console_write itself) if that happens. */
59
+ bool mik__proto_send_in_progress = false;
60
+
61
+ static MIKLogEmitFn s_log_emit_tap = nullptr;
62
+
63
+ void mik__set_log_emit_tap(MIKLogEmitFn fn) {
64
+ s_log_emit_tap = fn;
65
+ }
66
+
50
67
  /* Send a TLV-framed message: [ type: uint8 ] [ length: uint32_le ] [ payload ] */
51
68
  void mik__proto_send(MIKReplTransport* transport, uint8_t type, const void* data,
52
69
  size_t len) {
@@ -56,10 +73,12 @@ void mik__proto_send(MIKReplTransport* transport, uint8_t type, const void* data
56
73
  header[2] = (uint8_t)((len >> 8) & 0xFF);
57
74
  header[3] = (uint8_t)((len >> 16) & 0xFF);
58
75
  header[4] = (uint8_t)((len >> 24) & 0xFF);
76
+ mik__proto_send_in_progress = true;
59
77
  transport->write(header, MIK_PROTO_HEADER_SIZE, transport->ctx);
60
78
  if (len > 0 && data) {
61
79
  transport->write(data, len, transport->ctx);
62
80
  }
81
+ mik__proto_send_in_progress = false;
63
82
  }
64
83
 
65
84
  void mik__proto_send_ok(MIKReplTransport* transport) {
@@ -70,9 +89,13 @@ void mik__proto_send_err(MIKReplTransport* transport, const char* msg) {
70
89
  mik__proto_send(transport, MIK_MSG_ERR, msg, strlen(msg));
71
90
  }
72
91
 
73
- /* Public wrapper used by mik_console.cpp and mik_stdio.cpp */
92
+ /* Public wrapper used by mik_console.cpp and mik_stdio.cpp. Pre-frame
93
+ * tap fires before the TLV bytes go on the wire, so the file logger
94
+ * sees clean body text rather than [type][len][body]. */
74
95
  void mik__repl_proto_send_output(uint8_t msg_type, const void* data, size_t len) {
75
96
  if (repl_transport) {
97
+ MIKLogEmitFn tap = s_log_emit_tap;
98
+ if (tap) tap(msg_type, data, len);
76
99
  mik__proto_send(repl_transport, msg_type, data, len);
77
100
  }
78
101
  }
@@ -106,8 +129,11 @@ bool mik__proto_read_exact(MIKReplTransport* transport, void* buf, size_t n) {
106
129
  }
107
130
  /* Microtasks are deferred user JS: settling them re-enters .then
108
131
  * handlers that can write to the transport (console.log → MSG_LOG)
109
- * or touch the filesystem mid-deploy. Gate on pause too. */
110
- if (repl_ctx && !repl_paused) {
132
+ * or touch the filesystem mid-deploy. Gate on pause too — and
133
+ * on a pending panic-restart, since the runtime is on its way
134
+ * out and no further user JS should fire on it. */
135
+ if (repl_ctx && !repl_paused &&
136
+ (!repl_mik_rt || repl_mik_rt->restart_at_us == 0)) {
111
137
  mik__execute_jobs(repl_ctx);
112
138
  }
113
139
  /* A pumped job (e.g. the test runtime's __testFileDone) may
package/src/mikrojs.cpp CHANGED
@@ -145,18 +145,18 @@ static void mik__promise_rejection_tracker(JSContext* ctx, JSValue promise, JSVa
145
145
  * are genuine unhandled promise rejections. */
146
146
  bool in_promise = !mik__repl_is_evaluating();
147
147
  mik__report_uncaught(ctx, reason, in_promise);
148
- if (!MIK_IsReplActive()) {
149
- /* Notify the error handler (e.g. host bridge) directly.
150
- * Don't JS_Throw here we're inside a QuickJS callback
151
- * during promise resolution; throwing would corrupt engine
152
- * state and cause mik__execute_jobs to dump the error a
153
- * second time. */
154
- if (mik_rt->error_handler_fn) {
155
- mik_rt->error_handler_fn(ctx, reason, mik_rt->error_handler_opaque);
156
- }
157
- mik_rt->stop_requested = true;
158
- MIK_Stop(mik_rt);
148
+ /* Notify the error handler (e.g. host bridge) directly.
149
+ * Don't JS_Throw here we're inside a QuickJS callback
150
+ * during promise resolution; throwing would corrupt engine
151
+ * state and cause mik__execute_jobs to dump the error a
152
+ * second time. MIK_Stop itself gates on whether we're inside
153
+ * an interactive REPL eval so a typo at the prompt doesn't
154
+ * reboot the device. */
155
+ if (mik_rt->error_handler_fn) {
156
+ mik_rt->error_handler_fn(ctx, reason, mik_rt->error_handler_opaque);
159
157
  }
158
+ mik_rt->stop_requested = true;
159
+ MIK_Stop(mik_rt);
160
160
  return;
161
161
  }
162
162
 
@@ -534,6 +534,18 @@ void mik__execute_jobs(JSContext* ctx) {
534
534
 
535
535
  /* main loop which calls the user JS callbacks */
536
536
  int MIK_Loop(MIKRuntime* mik_rt) {
537
+ /* Deferred restart (see MIK_Stop): once the grace window elapses, reboot
538
+ * the device. While we're still in the window, return 0 without pumping
539
+ * timers/consumers so the protocol serve loop keeps reading host
540
+ * commands without firing any more user JS on the dead runtime. The
541
+ * serve loop also skips its microtask drain on this condition. */
542
+ if (mik_rt->restart_at_us > 0) {
543
+ const MIKPlatform* platform = MIK_GetPlatform();
544
+ if (platform->get_boot_us() >= mik_rt->restart_at_us) {
545
+ platform->restart();
546
+ }
547
+ return 0;
548
+ }
537
549
  if (mik_rt->stop_requested) {
538
550
  return 1;
539
551
  }
@@ -659,14 +671,31 @@ bool MIK_IsStopRequested(MIKRuntime* mik_rt) {
659
671
 
660
672
  void MIK_Stop(MIKRuntime* mik_rt) {
661
673
  CHECK_NOT_NULL(mik_rt);
662
- if (MIK_IsReplActive()) {
674
+ /* Only firmware (protocol REPL attached) auto-restarts on uncaught
675
+ * exceptions. Host embedders (Node addon, standalone tests) own their
676
+ * own process lifecycle and surface errors via the error handler. */
677
+ if (!MIK_IsReplActive()) {
663
678
  return;
664
679
  }
665
- const MIKPlatform* platform = MIK_GetPlatform();
666
- if (mik_rt->config.restart_on_uncaught_exception) {
667
- printf("Restarting in %d ms...\n", mik_rt->config.restart_delay_ms);
668
- usleep(mik_rt->config.restart_delay_ms * 1000);
669
- platform->restart();
680
+ /* A throw inside an interactive REPL eval is a user typo, not an app
681
+ * crash — don't reboot the device. */
682
+ if (mik__repl_is_evaluating()) {
683
+ return;
684
+ }
685
+ /* In test mode, the supervisor wants stop_requested to bubble up so it
686
+ * can synthesize a failing-test event and move to the next file. Arming
687
+ * a panic-restart here would reboot the device mid-manifest on the
688
+ * first async rejection. */
689
+ if (mik_rt->test_mode) {
690
+ return;
691
+ }
692
+ /* Defer the restart so the host can still issue deploy/clean/--recover
693
+ * commands during the grace window. The actual platform->restart()
694
+ * fires from MIK_Loop once the deadline elapses. */
695
+ if (mik_rt->restart_at_us == 0) {
696
+ const MIKPlatform* platform = MIK_GetPlatform();
697
+ mik_rt->restart_at_us =
698
+ platform->get_boot_us() + (int64_t)mik_rt->config.panic_restart_delay_ms * 1000;
670
699
  }
671
700
  }
672
701