@mikrojs/native 0.6.1 → 0.8.0-pr-85.g6768f8c

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.
@@ -23,15 +23,25 @@ typedef struct MIKRunOptions {
23
23
  bool use_psram_heap;
24
24
  } MIKRunOptions;
25
25
 
26
+ /* File-log flush policies. Mirror of `LogFlush` in the host-side TS type. */
27
+ typedef enum MIKLogFlush {
28
+ MIK_LOG_FLUSH_ERROR = 0, /* default: buffer, flush on warn/error or buffer full */
29
+ MIK_LOG_FLUSH_LINE = 1,
30
+ } MIKLogFlush;
31
+
26
32
  typedef struct MIKConfig {
27
- bool restart_on_uncaught_exception;
28
- int restart_delay_ms;
33
+ int panic_restart_delay_ms;
29
34
  size_t stack_size;
30
35
  uint32_t mem_reserved;
31
36
  uint32_t fs_read_max; /* 0 = keep runtime default (65536) */
32
37
  char entry_point[128];
33
38
  char wifi_country[3]; /* Two-letter country code + NUL, e.g. "NO" */
34
39
  char wifi_hostname[64]; /* DHCP hostname; empty = use mikrojs-<device-id> default */
40
+ /* File-log config. log_dir empty disables file logging; otherwise the
41
+ * file lives at "<log_dir>/log.txt" (rotated as ".../log.txt.1"). */
42
+ char log_dir[64];
43
+ uint32_t log_max_size;
44
+ MIKLogFlush log_flush;
35
45
  } MIKConfig;
36
46
 
37
47
  void MIK_DefaultConfig(MIKConfig* config);
@@ -306,6 +316,20 @@ void mik__proto_send(MIKReplTransport* transport, uint8_t type, const void* data
306
316
  void mik__proto_send_ok(MIKReplTransport* transport);
307
317
  void mik__proto_send_err(MIKReplTransport* transport, const char* msg);
308
318
 
319
+ /* Tap called by mik__repl_proto_send_output with the raw body bytes (no
320
+ * TLV framing) before they're sent on the wire. Single slot — owned by
321
+ * the file logger on the device side. msg_type is the MIK_MSG_* value so
322
+ * the tap can pick the appropriate log level. */
323
+ typedef void (*MIKLogEmitFn)(uint8_t msg_type, const void* data, size_t len);
324
+ void mik__set_log_emit_tap(MIKLogEmitFn fn);
325
+
326
+ /* True while mik__proto_send is writing TLV bytes to the transport.
327
+ * Platform-side transports (mik__console_write on the ESP32) check this
328
+ * to skip any installed wire-byte taps that would otherwise capture the
329
+ * framing in addition to the body. Single-task code path, so plain bool
330
+ * is sufficient — there's no other writer of this flag. */
331
+ extern bool mik__proto_send_in_progress;
332
+
309
333
  /* ── Session primitives ─────────────────────────────────────────────
310
334
  * A protocol session is scoped to a transport (one client connection).
311
335
  * Runtimes are attached/detached within the session; a supervisor can
@@ -56,6 +56,18 @@ struct MIKRuntime {
56
56
  bool is_worker;
57
57
  bool freeing;
58
58
  bool stop_requested; /* Set by promise rejection tracker to stop the loop */
59
+ /* Set by MIK_EnableTestHelpers — the test supervisor wants stop_requested
60
+ * to bubble up cleanly so it can synthesize a failing-test event and move
61
+ * to the next file. Without this flag, MIK_Stop would arm a panic-restart
62
+ * deadline and reboot the whole device mid-manifest on the first async
63
+ * rejection. */
64
+ bool test_mode;
65
+ /* Deferred restart deadline (boot_us, 0 = no pending restart). Set by
66
+ * MIK_Stop when an uncaught exception happens with a protocol REPL
67
+ * attached: the serve loop keeps reading commands until the deadline so
68
+ * the host can still deploy/clean/--recover during the grace window,
69
+ * then platform->restart() fires from MIK_Loop. */
70
+ int64_t restart_at_us;
59
71
  const char* fs_base_path;
60
72
  const char* fs_root; /* Sandbox root for mikrojs/fs operations (separate from module resolution) */
61
73
  size_t fs_limit; /* Max bytes for fs_root (0 = unlimited) */
@@ -259,6 +271,9 @@ void mik__repl_set_paused(bool paused);
259
271
  #define MIK_MSG_ERR 0x81
260
272
  #define MIK_MSG_CHECKSUM_RESULT 0x82
261
273
  #define MIK_MSG_CONFIG_ENTRIES 0x83
274
+ /* Chunk of file bytes streamed in response to MIK_CMD_FS_GET.
275
+ * Multiple frames precede a final MSG_OK signaling end-of-file. */
276
+ #define MIK_MSG_FS_CHUNK 0x84
262
277
 
263
278
  /* CLI → Device command types */
264
279
  #define MIK_CMD_EVAL 0x10
@@ -284,6 +299,10 @@ void mik__repl_set_paused(bool paused);
284
299
  #define MIK_CMD_RESTART 0x28
285
300
  #define MIK_CMD_RUNTIME_PAUSE 0x29
286
301
  #define MIK_CMD_RUNTIME_RESUME 0x2A
302
+ /* Pull a file off the device. Payload: u16le path_len | path.
303
+ * Device replies with zero-or-more MIK_MSG_FS_CHUNK frames followed by
304
+ * MIK_MSG_OK on EOF, or MIK_MSG_ERR if the path can't be opened. */
305
+ #define MIK_CMD_FS_GET 0x2B
287
306
 
288
307
  #define MIK_CMD_CONFIG_LIST 0x40
289
308
  #define MIK_CMD_CONFIG_SET 0x41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.6.1",
3
+ "version": "0.8.0-pr-85.g6768f8c",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -78,7 +78,7 @@
78
78
  "cmake-js": "^8.0.0",
79
79
  "node-addon-api": "^8.7.0",
80
80
  "node-gyp-build": "^4.8.4",
81
- "@mikrojs/quickjs": "0.6.1"
81
+ "@mikrojs/quickjs": "0.8.0-pr-85.g6768f8c"
82
82
  },
83
83
  "devDependencies": {
84
84
  "@swc/core": "^1.15.30",
package/runtime/fs/fs.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {ok} from 'mikrojs/result'
1
+ import {err, ok} from 'mikrojs/result'
2
2
  import * as native from 'native:fs'
3
3
 
4
4
  import type {Result} from '../result/types.js'
@@ -61,28 +61,26 @@ export const exists = native.exists
61
61
  export function readStream(
62
62
  path: string,
63
63
  options?: ReadStreamOptions,
64
- ): Result<AsyncIterable<Uint8Array>, FSError> {
64
+ ): Result<AsyncIterable<Result<Uint8Array, FSError>>, FSError> {
65
65
  const openResult = native.open(path)
66
66
  if (!openResult.ok) return openResult
67
67
  const fh = openResult.value
68
68
  const chunkSize = options?.chunkSize ?? 512
69
69
 
70
- async function* iterate(): AsyncIterable<Uint8Array> {
70
+ async function* iterate(): AsyncIterable<Result<Uint8Array, FSError>> {
71
71
  try {
72
72
  while (true) {
73
73
  const r = fh.read(chunkSize)
74
74
  if (!r.ok) {
75
75
  // Handle-level errors (BadFileDescriptor, Unknown) don't carry a
76
- // path from the native side. All other variants include `path`
77
- // when emitted by path-level ops but fh.read doesn't have the
78
- // path, so we decorate here to preserve the original readStream
79
- // context.
76
+ // path from the native side. Path-aware variants are decorated
77
+ // here so consumers see the original readStream context.
80
78
  const e = r.error
81
- if (e.name === 'BadFileDescriptor' || e.name === 'Unknown') throw e
82
- throw {...e, path}
79
+ yield e.name === 'BadFileDescriptor' || e.name === 'Unknown' ? err(e) : err({...e, path})
80
+ return
83
81
  }
84
82
  if (r.value === undefined) break
85
- yield r.value
83
+ yield ok(r.value)
86
84
  }
87
85
  } finally {
88
86
  fh.close()
@@ -79,8 +79,8 @@ export interface ReadStreamOptions {
79
79
 
80
80
  /**
81
81
  * Open a file as a stream of byte chunks. Returns a Result because the
82
- * initial open can fail; once iterating, mid-stream read errors propagate
83
- * via rejected promises from `next()`.
82
+ * initial open can fail; once iterating, mid-stream read errors arrive
83
+ * as a final `err(...)` item and end the stream.
84
84
  *
85
85
  * Compose with `mikrojs/stream` helpers (`decodeUtf8`, `splitLines`) for
86
86
  * text/line streams. Closes the underlying handle on EOF, mid-stream
@@ -89,4 +89,4 @@ export interface ReadStreamOptions {
89
89
  export declare function readStream(
90
90
  path: string,
91
91
  options?: ReadStreamOptions,
92
- ): Result<AsyncIterable<Uint8Array>, FSError>
92
+ ): Result<AsyncIterable<Result<Uint8Array, FSError>>, FSError>
@@ -3,7 +3,9 @@
3
3
  // LTE modem over UART) implement the `Request` type directly and reuse
4
4
  // `prepareBody` + `makeResponse` for the boring parts.
5
5
 
6
- import type {Result} from 'mikrojs/result'
6
+ import {err, ok} from 'mikrojs/result'
7
+
8
+ import type {Result} from '../result/types.js'
7
9
 
8
10
  export interface RequestOptions {
9
11
  method?: string
@@ -25,21 +27,24 @@ export interface Response {
25
27
  headers: [string, string][]
26
28
  ok: boolean
27
29
  /**
28
- * Response body as an async iterable of chunks. Single-shot: after any of
29
- * `body`, `text()`, `bytes()`, or `json()` has started draining, calling
30
- * another consumer throws `BodyConsumedError`.
30
+ * Response body as an async iterable of byte chunks. Iteration yields
31
+ * `Result<Uint8Array, RequestError>` so mid-stream failures (network
32
+ * drop, abort, timeout) compose with the rest of the Result-based API.
33
+ * Single-shot: after any of `body`, `text()`, `bytes()`, or `json()`
34
+ * has started draining, the next consumer call throws
35
+ * `BodyConsumedError`.
31
36
  */
32
- body: AsyncIterable<Uint8Array>
37
+ body: AsyncIterable<Result<Uint8Array, RequestError>>
33
38
  /** First matching header value, case-insensitive, or undefined if absent. */
34
39
  get(name: string): string | undefined
35
40
  /** All matching header values in order, case-insensitive. Empty array if absent. */
36
41
  getAll(name: string): string[]
37
42
  /** Drain body as a UTF-8 string. Throws `BodyConsumedError` on second consumer call. */
38
- text(): Promise<string>
43
+ text(): Promise<Result<string, RequestError>>
39
44
  /** Drain body and parse as JSON. Throws `BodyConsumedError` on second consumer call. */
40
- json(): Promise<unknown>
45
+ json(): Promise<Result<unknown, RequestError>>
41
46
  /** Drain body to a single `Uint8Array`. Throws `BodyConsumedError` on second consumer call. */
42
- bytes(): Promise<Uint8Array>
47
+ bytes(): Promise<Result<Uint8Array, RequestError>>
43
48
  /**
44
49
  * Cancel the request and release any native resources. Safe to call after
45
50
  * the body has been consumed (no-op). Safe to call multiple times.
@@ -63,16 +68,11 @@ export const RequestError = {
63
68
  Aborted: (message: string) => ({name: 'Aborted', message}) as const,
64
69
  /** Transport is at its concurrent-request cap. Retry after in-flight requests settle. */
65
70
  TooManyPending: () => ({name: 'TooManyPending'}) as const,
71
+ /** Body drained but JSON.parse rejected the payload. */
72
+ InvalidJson: (message: string) => ({name: 'InvalidJson', message}) as const,
66
73
  }
67
74
 
68
- export type RequestError =
69
- | ReturnType<typeof RequestError.Hardware>
70
- | ReturnType<typeof RequestError.Network>
71
- | ReturnType<typeof RequestError.Timeout>
72
- | ReturnType<typeof RequestError.BodyTooLarge>
73
- | ReturnType<typeof RequestError.InvalidResponse>
74
- | ReturnType<typeof RequestError.Aborted>
75
- | ReturnType<typeof RequestError.TooManyPending>
75
+ export type RequestError = ReturnType<(typeof RequestError)[keyof typeof RequestError]>
76
76
 
77
77
  export class BodyConsumedError extends Error {
78
78
  readonly name = 'BodyConsumed'
@@ -99,9 +99,9 @@ export function prepareBody(opts: RequestOptions): {
99
99
  }
100
100
 
101
101
  /**
102
- * Wrap a transport's raw response (async-iterable body) into the public
103
- * `Response` shape with `text()`/`json()`/`bytes()`/`get()`/`getAll()`/`close()`
104
- * and a single-shot body claim.
102
+ * Wrap a transport's raw response (Result-yielding async-iterable body) into
103
+ * the public `Response` shape with `text()`/`json()`/`bytes()`/`get()`/
104
+ * `getAll()`/`close()` and a single-shot body claim.
105
105
  */
106
106
  export function makeResponse(raw: {
107
107
  status: number
@@ -109,7 +109,7 @@ export function makeResponse(raw: {
109
109
  url: string
110
110
  redirected: boolean
111
111
  headers: [string, string][]
112
- body: AsyncIterable<Uint8Array>
112
+ body: AsyncIterable<Result<Uint8Array, RequestError>>
113
113
  }): Response {
114
114
  let consumed = false
115
115
  const claim = () => {
@@ -117,7 +117,7 @@ export function makeResponse(raw: {
117
117
  consumed = true
118
118
  }
119
119
 
120
- const body: AsyncIterable<Uint8Array> = {
120
+ const body: AsyncIterable<Result<Uint8Array, RequestError>> = {
121
121
  [Symbol.asyncIterator]() {
122
122
  claim()
123
123
  return raw.body[Symbol.asyncIterator]()
@@ -153,7 +153,13 @@ export function makeResponse(raw: {
153
153
  },
154
154
  json: async () => {
155
155
  claim()
156
- return JSON.parse(await drainAsText(raw.body))
156
+ const text = await drainAsText(raw.body)
157
+ if (!text.ok) return text
158
+ try {
159
+ return ok(JSON.parse(text.value))
160
+ } catch (e) {
161
+ return err(RequestError.InvalidJson(e instanceof Error ? e.message : String(e)))
162
+ }
157
163
  },
158
164
  bytes: async () => {
159
165
  claim()
@@ -177,29 +183,35 @@ function normalizeHeaders(
177
183
  return out
178
184
  }
179
185
 
180
- async function drain(source: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
186
+ async function drain(
187
+ source: AsyncIterable<Result<Uint8Array, RequestError>>,
188
+ ): Promise<Result<Uint8Array, RequestError>> {
181
189
  const parts: Uint8Array[] = []
182
190
  let total = 0
183
- for await (const chunk of source) {
184
- parts.push(chunk)
185
- total += chunk.length
191
+ for await (const r of source) {
192
+ if (!r.ok) return r
193
+ parts.push(r.value)
194
+ total += r.value.length
186
195
  }
187
- if (parts.length === 1) return parts[0]!
196
+ if (parts.length === 1) return ok(parts[0]!)
188
197
  const out = new Uint8Array(total)
189
198
  let offset = 0
190
199
  for (const p of parts) {
191
200
  out.set(p, offset)
192
201
  offset += p.length
193
202
  }
194
- return out
203
+ return ok(out)
195
204
  }
196
205
 
197
- async function drainAsText(source: AsyncIterable<Uint8Array>): Promise<string> {
206
+ async function drainAsText(
207
+ source: AsyncIterable<Result<Uint8Array, RequestError>>,
208
+ ): Promise<Result<string, RequestError>> {
198
209
  const decoder = new TextDecoder()
199
210
  let out = ''
200
- for await (const chunk of source) {
201
- out += decoder.decode(chunk, {stream: true})
211
+ for await (const r of source) {
212
+ if (!r.ok) return r
213
+ out += decoder.decode(r.value, {stream: true})
202
214
  }
203
215
  out += decoder.decode()
204
- return out
216
+ return ok(out)
205
217
  }
@@ -92,10 +92,10 @@ export function createRequestFromNative(native: NativeHttpModule): Request {
92
92
  const {status, headers: responseHeaders} = headersResult.value
93
93
 
94
94
  let done = false
95
- const rawBody: AsyncIterable<Uint8Array> = {
95
+ const rawBody: AsyncIterable<Result<Uint8Array, RequestError>> = {
96
96
  [Symbol.asyncIterator]() {
97
97
  return {
98
- async next() {
98
+ async next(): Promise<IteratorResult<Result<Uint8Array, RequestError>>> {
99
99
  if (done) return {done: true, value: undefined}
100
100
  let msg
101
101
  try {
@@ -103,19 +103,28 @@ export function createRequestFromNative(native: NativeHttpModule): Request {
103
103
  } catch (e) {
104
104
  done = true
105
105
  cleanup()
106
- throw e
106
+ const message = e instanceof Error ? e.message : String(e)
107
+ return {done: false, value: err(RequestError.Network(message))}
107
108
  }
108
109
  if (msg.kind === 'chunk') {
109
- return {done: false, value: msg.data}
110
+ return {done: false, value: ok(msg.data)}
110
111
  }
111
112
  done = true
112
113
  cleanup()
113
114
  if (msg.kind === 'end') return {done: true, value: undefined}
114
115
  /* error */
115
116
  if (msg.cancelled) {
116
- throw RequestError.Aborted(msg.message || String(options.signal?.reason ?? 'aborted'))
117
+ return {
118
+ done: false,
119
+ value: err(
120
+ RequestError.Aborted(msg.message || String(options.signal?.reason ?? 'aborted')),
121
+ ),
122
+ }
123
+ }
124
+ return {
125
+ done: false,
126
+ value: err(RequestError.Network(msg.message || 'HTTP request failed')),
117
127
  }
118
- throw RequestError.Network(msg.message || 'HTTP request failed')
119
128
  },
120
129
  async return() {
121
130
  if (!done) {
@@ -318,8 +318,8 @@ declare module 'native:uart' {
318
318
 
319
319
  read(): Result<
320
320
  {
321
- next(): Promise<IteratorResult<Uint8Array>>
322
- return(): Promise<IteratorResult<Uint8Array>>
321
+ next(): Promise<IteratorResult<Result<Uint8Array, UartError>>>
322
+ return(): Promise<IteratorResult<Result<Uint8Array, UartError>>>
323
323
  },
324
324
  UartError
325
325
  >
@@ -1,4 +1,4 @@
1
- import {defineError, err, ok} from 'mikrojs/result'
1
+ import {err, ok} from 'mikrojs/result'
2
2
  // Typed loosely to avoid deep type instantiation from Infer<S> in parse's generics.
3
3
  // Type safety is provided by the storage interface overloads in types.ts.
4
4
  import {parse as _parse} from 'mikrojs/schema'
@@ -8,12 +8,14 @@ import type {NativeError} from '../result/types.js'
8
8
  type ParseResult = {ok: true; value: unknown} | {ok: false; error: {message: string; path: string}}
9
9
  const parse = _parse as unknown as (schema: unknown, value: unknown) => ParseResult
10
10
 
11
- export const KVError = defineError('KVError', {
12
- StorageFull: (message: string) => ({message}),
13
- EncodeFailed: (message: string) => ({message}),
14
- WriteFailed: (message: string) => ({message}),
15
- ValidationFailed: (message: string, path: string) => ({message, path}),
16
- })
11
+ export const KVError = {
12
+ StorageFull: (message: string) => ({name: 'StorageFull', message}) as const,
13
+ EncodeFailed: (message: string) => ({name: 'EncodeFailed', message}) as const,
14
+ WriteFailed: (message: string) => ({name: 'WriteFailed', message}) as const,
15
+ ValidationFailed: (message: string, path: string) =>
16
+ ({name: 'ValidationFailed', message, path}) as const,
17
+ Unknown: (code: number, message: string) => ({name: 'Unknown', code, message}) as const,
18
+ }
17
19
 
18
20
  /* Error codes from errors.h (MIK_ERR_BASE + 0xD0..0xD4) */
19
21
  const KV_INVALID_KEY = 0x80d0
@@ -43,7 +45,7 @@ function mapKvError(e: NativeError) {
43
45
  case KV_WRITE:
44
46
  return KVError.WriteFailed(e.message)
45
47
  default:
46
- throw new Error(`unexpected native kv error: code=0x${e.code.toString(16)}`)
48
+ return KVError.Unknown(e.code, e.message)
47
49
  }
48
50
  }
49
51
 
@@ -55,6 +55,7 @@ export type KVError =
55
55
  | {name: 'EncodeFailed'; message: string}
56
56
  | {name: 'WriteFailed'; message: string}
57
57
  | {name: 'ValidationFailed'; message: string; path: string}
58
+ | {name: 'Unknown'; code: number; message: string}
58
59
 
59
60
  export type KVOptions<S> =
60
61
  | {
@@ -9,12 +9,19 @@
9
9
  * lazy-load efficiency.
10
10
  */
11
11
 
12
+ import {err, ok} from 'mikrojs/result'
13
+
14
+ import type {Result} from '../result/types.js'
15
+
16
+ export type ReaderError = {name: 'Timeout'; ms: number} | {name: 'StreamClosed'}
17
+
12
18
  /**
13
- * A buffered byte reader over an async iterator of byte chunks.
19
+ * A buffered byte reader over a Result-yielding async iterator of
20
+ * byte chunks.
14
21
  *
15
- * Wraps any `AsyncIterable<Uint8Array>` (typically `uart.read()`,
16
- * eventually also TCP sockets) and exposes three primitives that
17
- * share the same underlying byte buffer:
22
+ * Wraps any `AsyncIterable<Result<Uint8Array, E>>` (typically
23
+ * `uart.read()`, `fs.readStream`, an HTTP body) and exposes three
24
+ * primitives that share the same underlying byte buffer:
18
25
  *
19
26
  * - `readUntil(delimiter, {timeoutMs})` — pull chunks until the
20
27
  * buffer contains `delimiter`, return everything before it and
@@ -30,12 +37,12 @@
30
37
  * underlying iterator. Useful before sending a new command when
31
38
  * stale bytes from a previous response might be left over.
32
39
  *
33
- * Both `readUntil` and `readBytes` throw `TimeoutError` (an Error
34
- * with `name === 'TimeoutError'`) if the deadline expires before
35
- * the request is satisfied, and `StreamClosed` (an Error with
36
- * `name === 'StreamClosed'`) if the underlying iterator finishes
37
- * before the request is satisfied. Callers may wrap those in their
38
- * own Result type if they prefer structured error handling.
40
+ * Both `readUntil` and `readBytes` return `Result<Uint8Array, E |
41
+ * ReaderError>`: `err({name: 'Timeout', ms})` if the deadline expires
42
+ * before the request is satisfied, `err({name: 'StreamClosed'})` if
43
+ * the underlying iterator finishes early, or whatever error variant
44
+ * the source yields. `RangeError` is still thrown for invalid
45
+ * arguments (programmer mistakes, not stream errors).
39
46
  *
40
47
  * Timeout semantics: when a pull races against a `setTimeout` and
41
48
  * the timer wins, the pending `iter.next()` promise is kept so the
@@ -48,18 +55,21 @@
48
55
  * not protected against — the caller is responsible for
49
56
  * sequential ordering.
50
57
  */
51
- export class BufferedReader {
52
- readonly #source: AsyncIterator<Uint8Array>
58
+ export class BufferedReader<E = never> {
59
+ readonly #source: AsyncIterator<Result<Uint8Array, E>>
53
60
  #buffer: Uint8Array
54
- #pending: Promise<IteratorResult<Uint8Array>> | null
61
+ #pending: Promise<IteratorResult<Result<Uint8Array, E>>> | null
55
62
 
56
- constructor(source: AsyncIterable<Uint8Array>) {
57
- this.#source = source[Symbol.asyncIterator]() as AsyncIterator<Uint8Array>
63
+ constructor(source: AsyncIterable<Result<Uint8Array, E>>) {
64
+ this.#source = source[Symbol.asyncIterator]() as AsyncIterator<Result<Uint8Array, E>>
58
65
  this.#buffer = new Uint8Array(0)
59
66
  this.#pending = null
60
67
  }
61
68
 
62
- async readUntil(delimiter: Uint8Array, options: {timeoutMs: number}): Promise<Uint8Array> {
69
+ async readUntil(
70
+ delimiter: Uint8Array,
71
+ options: {timeoutMs: number},
72
+ ): Promise<Result<Uint8Array, E | ReaderError>> {
63
73
  if (delimiter.length === 0) {
64
74
  throw new RangeError('BufferedReader.readUntil: delimiter must be non-empty')
65
75
  }
@@ -69,24 +79,29 @@ export class BufferedReader {
69
79
  if (idx !== -1) {
70
80
  const out = this.#buffer.slice(0, idx)
71
81
  this.#buffer = this.#buffer.slice(idx + delimiter.length)
72
- return out
82
+ return ok(out)
73
83
  }
74
- await this.#pull(deadline)
84
+ const pull = await this.#pull(deadline, options.timeoutMs)
85
+ if (!pull.ok) return pull
75
86
  }
76
87
  }
77
88
 
78
- async readBytes(count: number, options: {timeoutMs: number}): Promise<Uint8Array> {
89
+ async readBytes(
90
+ count: number,
91
+ options: {timeoutMs: number},
92
+ ): Promise<Result<Uint8Array, E | ReaderError>> {
79
93
  if (count < 0) {
80
94
  throw new RangeError('BufferedReader.readBytes: count must be non-negative')
81
95
  }
82
- if (count === 0) return new Uint8Array(0)
96
+ if (count === 0) return ok(new Uint8Array(0))
83
97
  const deadline = Date.now() + options.timeoutMs
84
98
  while (this.#buffer.length < count) {
85
- await this.#pull(deadline)
99
+ const pull = await this.#pull(deadline, options.timeoutMs)
100
+ if (!pull.ok) return pull
86
101
  }
87
102
  const out = this.#buffer.slice(0, count)
88
103
  this.#buffer = this.#buffer.slice(count)
89
- return out
104
+ return ok(out)
90
105
  }
91
106
 
92
107
  /**
@@ -100,13 +115,9 @@ export class BufferedReader {
100
115
  this.#buffer = new Uint8Array(0)
101
116
  }
102
117
 
103
- async #pull(deadline: number): Promise<void> {
118
+ async #pull(deadline: number, timeoutMs: number): Promise<Result<void, E | ReaderError>> {
104
119
  const remaining = deadline - Date.now()
105
- if (remaining <= 0) {
106
- const err = new Error(`BufferedReader: timeout waiting for bytes`)
107
- err.name = 'TimeoutError'
108
- throw err
109
- }
120
+ if (remaining <= 0) return err({name: 'Timeout' as const, ms: timeoutMs})
110
121
 
111
122
  if (this.#pending === null) {
112
123
  this.#pending = this.#source.next()
@@ -116,24 +127,24 @@ export class BufferedReader {
116
127
  let timerId: ReturnType<typeof setTimeout> | undefined
117
128
  try {
118
129
  const result = await Promise.race([
119
- pending.then((v): {kind: 'value'; v: IteratorResult<Uint8Array>} => ({kind: 'value', v})),
130
+ pending.then((v): {kind: 'value'; v: IteratorResult<Result<Uint8Array, E>>} => ({
131
+ kind: 'value',
132
+ v,
133
+ })),
120
134
  new Promise<{kind: 'timeout'}>((resolve) => {
121
135
  timerId = setTimeout(() => resolve({kind: 'timeout'}), remaining)
122
136
  }),
123
137
  ])
124
138
  if (result.kind === 'timeout') {
125
139
  // Leave #pending intact so the next pull resumes on this chunk.
126
- const err = new Error(`BufferedReader: timeout after ${remaining}ms waiting for bytes`)
127
- err.name = 'TimeoutError'
128
- throw err
140
+ return err({name: 'Timeout' as const, ms: timeoutMs})
129
141
  }
130
142
  this.#pending = null
131
- if (result.v.done) {
132
- const err = new Error('BufferedReader: stream closed before read satisfied')
133
- err.name = 'StreamClosed'
134
- throw err
135
- }
136
- this.#buffer = concatBytes(this.#buffer, result.v.value)
143
+ if (result.v.done) return err({name: 'StreamClosed' as const})
144
+ const item = result.v.value
145
+ if (!item.ok) return item
146
+ this.#buffer = concatBytes(this.#buffer, item.value)
147
+ return ok()
137
148
  } finally {
138
149
  if (timerId !== undefined) clearTimeout(timerId)
139
150
  }
@@ -7,10 +7,14 @@
7
7
  * `packages/mikrojs` and by user apps via their tsconfig.
8
8
  */
9
9
 
10
+ import type {Result} from 'mikrojs/result'
11
+
12
+ export type ReaderError = {name: 'Timeout'; ms: number} | {name: 'StreamClosed'}
13
+
10
14
  /**
11
- * A buffered byte reader over an async iterator of `Uint8Array`
12
- * chunks. Offers three operations that share the same underlying
13
- * buffer:
15
+ * A buffered byte reader over a Result-yielding async iterator of
16
+ * `Uint8Array` chunks. Offers three operations that share the same
17
+ * underlying buffer:
14
18
  *
15
19
  * - `readUntil(delimiter, {timeoutMs})` — return everything before
16
20
  * the first occurrence of `delimiter`, consume the delimiter.
@@ -19,16 +23,23 @@
19
23
  * - `drain()` — discard buffered bytes without affecting the
20
24
  * underlying iterator.
21
25
  *
22
- * Throws an Error with `name === 'TimeoutError'` if the deadline
23
- * expires before the request is satisfied, or `name === 'StreamClosed'`
24
- * if the underlying iterator finishes before the request is satisfied.
26
+ * Returns `err({name: 'Timeout', ms})` if the deadline expires,
27
+ * `err({name: 'StreamClosed'})` if the underlying iterator finishes
28
+ * early, or the source's own error variant when it yields one.
29
+ * `RangeError` is still thrown for invalid arguments.
25
30
  *
26
31
  * Single-consumer only: concurrent `readUntil` / `readBytes` calls on
27
32
  * the same instance are unsupported.
28
33
  */
29
- export declare class BufferedReader {
30
- constructor(source: AsyncIterable<Uint8Array>)
31
- readUntil(delimiter: Uint8Array, options: {timeoutMs: number}): Promise<Uint8Array>
32
- readBytes(count: number, options: {timeoutMs: number}): Promise<Uint8Array>
34
+ export declare class BufferedReader<E = never> {
35
+ constructor(source: AsyncIterable<Result<Uint8Array, E>>)
36
+ readUntil(
37
+ delimiter: Uint8Array,
38
+ options: {timeoutMs: number},
39
+ ): Promise<Result<Uint8Array, E | ReaderError>>
40
+ readBytes(
41
+ count: number,
42
+ options: {timeoutMs: number},
43
+ ): Promise<Result<Uint8Array, E | ReaderError>>
33
44
  drain(): void
34
45
  }
@@ -7,20 +7,9 @@ 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]>
25
- }
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
+ return (handlers as unknown as Record<string, (e: E) => R>)[error.name]!(error)
26
15
  }
@@ -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
@@ -106,28 +119,30 @@ export async function collectUntil<T>(
106
119
  * timeout, or consumer break) so underlying resources (UART claims,
107
120
  * socket handles, ring buffers) get released.
108
121
  */
109
- export async function* withTimeout<T>(source: AsyncIterable<T>, ms: number): AsyncIterable<T> {
122
+ export async function* withTimeout<T, E>(
123
+ source: AsyncIterable<Result<T, E>>,
124
+ ms: number,
125
+ ): AsyncIterable<Result<T, E | {name: 'Timeout'; ms: number}>> {
110
126
  const deadline = Date.now() + ms
111
127
  const iter = source[Symbol.asyncIterator]()
112
128
  try {
113
129
  while (true) {
114
130
  const remaining = deadline - Date.now()
115
131
  if (remaining <= 0) {
116
- const err = new Error(`Stream did not complete within ${ms}ms`)
117
- err.name = 'TimeoutError'
118
- throw err
132
+ yield err({name: 'Timeout' as const, ms})
133
+ return
119
134
  }
120
135
 
121
136
  let timerId: ReturnType<typeof setTimeout> | undefined
122
- let result: IteratorResult<T, unknown>
137
+ let result: IteratorResult<Result<T, E>, unknown>
138
+ let timedOut = false
123
139
  try {
124
140
  result = await Promise.race([
125
141
  iter.next(),
126
- new Promise<IteratorResult<T, unknown>>((_, reject) => {
142
+ new Promise<IteratorResult<Result<T, E>, unknown>>((resolve) => {
127
143
  timerId = setTimeout(() => {
128
- const err = new Error(`Stream did not complete within ${ms}ms`)
129
- err.name = 'TimeoutError'
130
- reject(err)
144
+ timedOut = true
145
+ resolve({done: true, value: undefined})
131
146
  }, remaining)
132
147
  }),
133
148
  ])
@@ -135,8 +150,13 @@ export async function* withTimeout<T>(source: AsyncIterable<T>, ms: number): Asy
135
150
  if (timerId !== undefined) clearTimeout(timerId)
136
151
  }
137
152
 
153
+ if (timedOut) {
154
+ yield err({name: 'Timeout' as const, ms})
155
+ return
156
+ }
138
157
  if (result.done) return
139
- yield result.value as T
158
+ yield result.value
159
+ if (!result.value.ok) return
140
160
  }
141
161
  } finally {
142
162
  if (typeof iter.return === 'function') {
@@ -5,42 +5,56 @@
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.
42
53
  */
43
- export declare function withTimeout<T>(source: AsyncIterable<T>, ms: number): AsyncIterable<T>
54
+ export declare function withTimeout<T, E>(
55
+ source: AsyncIterable<Result<T, E>>,
56
+ ms: number,
57
+ ): AsyncIterable<Result<T, E | Extract<StreamError, {name: 'Timeout'}>>>
44
58
 
45
59
  /* BufferedReader lives in the sibling `mikrojs/reader` module — see
46
60
  * `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