@mikrojs/native 0.18.0-pr-306.20260731215718 → 0.18.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.
@@ -57,6 +57,9 @@ struct MIKRejectedPromise {
57
57
  JSValue reason;
58
58
  };
59
59
 
60
+ /* Observable dispatch queue (defined in mik_observable.cpp). */
61
+ struct MIKObservableDispatch;
62
+
60
63
  struct MIKRuntime {
61
64
  MIKRunOptions options;
62
65
  MIKConfig config;
@@ -170,6 +173,10 @@ struct MIKRuntime {
170
173
  * through the wire protocol. NULL means __testEmit is a no-op. */
171
174
  void (*test_emit_fn)(const char* json, size_t len, void* opaque) = nullptr;
172
175
  void* test_emit_opaque = nullptr;
176
+ /* Observable dispatch trampoline state (FIFO + active flag). Allocated by
177
+ * mik__observable_init, freed via mik__observable_dispatch_free. Empty
178
+ * outside an active dispatch, so teardown never sees live JSValues. */
179
+ struct MIKObservableDispatch* observable_dispatch = nullptr;
173
180
  };
174
181
 
175
182
  void mik__pub_fs_register(JSContext* ctx);
@@ -259,6 +266,7 @@ JSModuleDef* mik__udp_init(JSContext* ctx);
259
266
 
260
267
  /* Observable module (mik_observable.cpp) */
261
268
  JSModuleDef* mik__observable_init(JSContext* ctx);
269
+ void mik__observable_dispatch_free(struct MIKRuntime* mik_rt);
262
270
 
263
271
  bool mik__repl_is_evaluating(void);
264
272
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.18.0-pr-306.20260731215718",
3
+ "version": "0.18.0",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -86,14 +86,14 @@
86
86
  "cmake-js": "^8.0.0",
87
87
  "node-addon-api": "^8.7.0",
88
88
  "node-gyp-build": "^4.8.4",
89
- "@mikrojs/quickjs": "0.18.0-pr-306.20260731215718+751f499"
89
+ "@mikrojs/quickjs": "0.18.0"
90
90
  },
91
91
  "devDependencies": {
92
92
  "@swc/core": "^1.15.30",
93
93
  "@types/node": "^24.12.2",
94
94
  "esbuild": "^0.28.0",
95
95
  "terser": "^5.46.2",
96
- "@mikrojs/registry": "0.18.0-pr-306.20260731215718+751f499"
96
+ "@mikrojs/registry": "0.18.0"
97
97
  },
98
98
  "engines": {
99
99
  "node": ">=24.0.0"
@@ -5,24 +5,48 @@
5
5
  // - subscribe() returns a Subscription with unsubscribe() (no AbortSignal)
6
6
  // - no error channel — throws inside dispatch or teardown are caught at the
7
7
  // boundary, isolated to the offending subscriber, and re-thrown
8
- // asynchronously via setTimeout(0) so the synchronous producer keeps
9
- // running but the bug eventually surfaces (and on device, halts the
10
- // runtime via the existing unhandled-rejection path)
8
+ // asynchronously via setTimeout(0) so the synchronous producer finishes
9
+ // the dispatch; on device the deferred throw then panics
11
10
  // - sync emission allowed
12
11
  // - pipe-only composition (operators live in operators.ts)
13
12
  // - withEmitters() factory: {observable, next, complete}
14
13
 
15
- /* Catch a thrown error and re-throw it on the next tick. The synchronous
16
- * caller keeps going (other subscribers receive the value, remaining
17
- * teardowns run); the error eventually surfaces as an uncaught exception. */
18
- function panicAsync(err: unknown): void {
14
+ /* A throw in a subscriber, operator, or teardown callback is an application
15
+ * crash. On device it stops the runtime per onPanic, so nothing further is
16
+ * delivered. There is no runtime to stop here, so the host sees an uncaught
17
+ * error on the next tick and `panicked` suppresses further delivery, which is
18
+ * the part a unit test can observe. It clears on the next fresh subscribe, so
19
+ * one test's crash cannot poison the next; on device the runtime is gone. */
20
+ let panicked = false
21
+
22
+ function panic(err: unknown): void {
23
+ panicked = true
19
24
  setTimeout(() => {
20
25
  throw err
21
26
  }, 0)
22
27
  }
23
28
 
29
+ /* Dispatch trampoline, mirroring mik_observable.cpp: next/complete called
30
+ * while a dispatch is active enqueue instead of recursing; the outermost
31
+ * dispatch drains in FIFO order. Handler code after sub.next() therefore
32
+ * runs before downstream delivery. */
33
+ type QueueEntry = {sub: Subscriber<unknown>; value: unknown; isComplete: boolean}
34
+ const queue: QueueEntry[] = []
35
+ let queueHead = 0
36
+ let dispatchActive = false
37
+
38
+ function drainQueue(): void {
39
+ while (queueHead < queue.length) {
40
+ const e = queue[queueHead++]!
41
+ if (!panicked) e.sub.deliverQueued(e)
42
+ }
43
+ queue.length = 0
44
+ queueHead = 0
45
+ }
46
+
24
47
  class Subscriber<T> {
25
- closed = false
48
+ private _closed = false
49
+ private completePending = false
26
50
  private next_fn: ((v: T) => void) | undefined
27
51
  private complete_fn: (() => void) | undefined
28
52
  private teardowns: Array<() => void> = []
@@ -40,25 +64,61 @@ class Subscriber<T> {
40
64
  }
41
65
  }
42
66
 
67
+ get closed(): boolean {
68
+ return this._closed || this.completePending
69
+ }
70
+
43
71
  next(value: T): void {
44
- if (this.closed) return
72
+ if (this.closed || panicked) return
73
+ if (dispatchActive) {
74
+ queue.push({sub: this as Subscriber<unknown>, value, isComplete: false})
75
+ return
76
+ }
77
+ dispatchActive = true
78
+ this.deliverNext(value)
79
+ drainQueue()
80
+ dispatchActive = false
81
+ }
82
+
83
+ complete(): void {
84
+ if (this.closed || panicked) return
85
+ if (dispatchActive) {
86
+ this.completePending = true
87
+ queue.push({sub: this as Subscriber<unknown>, value: undefined, isComplete: true})
88
+ return
89
+ }
90
+ dispatchActive = true
91
+ this.deliverComplete()
92
+ drainQueue()
93
+ dispatchActive = false
94
+ }
95
+
96
+ /* Deliver a queued entry; entries whose subscriber closed (unsubscribed)
97
+ * between enqueue and drain are dropped. */
98
+ deliverQueued(e: QueueEntry): void {
99
+ if (this._closed) return
100
+ if (e.isComplete) this.deliverComplete()
101
+ else this.deliverNext(e.value as T)
102
+ }
103
+
104
+ private deliverNext(value: T): void {
45
105
  if (this.next_fn) {
46
106
  try {
47
107
  this.next_fn(value)
48
108
  } catch (err) {
49
- panicAsync(err)
109
+ panic(err)
50
110
  }
51
111
  }
52
112
  }
53
113
 
54
- complete(): void {
55
- if (this.closed) return
56
- this.closed = true
114
+ private deliverComplete(): void {
115
+ this.completePending = false
116
+ this._closed = true
57
117
  if (this.complete_fn) {
58
118
  try {
59
119
  this.complete_fn()
60
120
  } catch (err) {
61
- panicAsync(err)
121
+ panic(err)
62
122
  }
63
123
  }
64
124
  this.runTeardowns()
@@ -68,11 +128,11 @@ class Subscriber<T> {
68
128
  if (typeof fn !== 'function') {
69
129
  throw new TypeError('addTeardown: argument must be a function')
70
130
  }
71
- if (this.closed) {
131
+ if (this._closed) {
72
132
  try {
73
133
  fn()
74
134
  } catch (err) {
75
- panicAsync(err)
135
+ panic(err)
76
136
  }
77
137
  return
78
138
  }
@@ -80,9 +140,10 @@ class Subscriber<T> {
80
140
  }
81
141
 
82
142
  // Used by Subscription.unsubscribe — silent (no observer.complete call).
143
+ // A pending complete owns the close; its queued entry still delivers.
83
144
  closeSilently(): void {
84
145
  if (this.closed) return
85
- this.closed = true
146
+ this._closed = true
86
147
  this.runTeardowns()
87
148
  }
88
149
 
@@ -93,7 +154,7 @@ class Subscriber<T> {
93
154
  try {
94
155
  list[i]!()
95
156
  } catch (err) {
96
- panicAsync(err)
157
+ panic(err)
97
158
  }
98
159
  }
99
160
  }
@@ -122,6 +183,7 @@ export class Observable<Ok, Err = never> {
122
183
  }
123
184
 
124
185
  subscribe(observer?: unknown): Subscription {
186
+ if (!dispatchActive) panicked = false
125
187
  const sub = new Subscriber<unknown>(observer)
126
188
  try {
127
189
  this.#cb(sub)
@@ -167,7 +229,7 @@ export class Observable<Ok, Err = never> {
167
229
  ) {
168
230
  return new Observable<unknown>((sub) => {
169
231
  for (const value of src as Iterable<unknown>) {
170
- if (sub.closed) return
232
+ if (sub.closed || panicked) return
171
233
  sub.next(value)
172
234
  }
173
235
  if (!sub.closed) sub.complete()
@@ -201,6 +263,7 @@ export class Observable<Ok, Err = never> {
201
263
  // Snapshot to be resilient against mid-dispatch unsubscribes.
202
264
  const snapshot = subs.slice()
203
265
  for (const s of snapshot) {
266
+ if (panicked) break
204
267
  if (!s.closed) s.next(value)
205
268
  }
206
269
  }
@@ -211,6 +274,7 @@ export class Observable<Ok, Err = never> {
211
274
  const snapshot = subs.slice()
212
275
  subs.length = 0
213
276
  for (const s of snapshot) {
277
+ if (panicked) break
214
278
  if (!s.closed) s.complete()
215
279
  }
216
280
  }
@@ -7,12 +7,9 @@
7
7
  * Result-aware operators (`mapOk`, `filterOk`, ...) ship when a concrete
8
8
  * consumer asks. Today no module produces fallible event streams.
9
9
  *
10
- * Errors: throws inside operator transforms or finalize callbacks are caught
11
- * at the dispatch boundary, isolated to that subscriber, and re-thrown
12
- * asynchronously via setTimeout(0). The synchronous producer keeps going
13
- * (the bad value is dropped, sibling subscribers untouched), and on device
14
- * the eventual uncaught throw halts the runtime via the existing
15
- * unhandled-rejection path. Stream errors are panics.
10
+ * Errors: a throw inside a transform or a finalize callback propagates to the
11
+ * dispatch boundary, which reports it and panics. Operators do not catch:
12
+ * an application crash is an application crash.
16
13
  *
17
14
  * See `.claude/plans/observable.md` for the full design.
18
15
  */
@@ -28,69 +25,42 @@ import type {Observable as ObservableT} from './types.js'
28
25
  const Observable = NativeObservable as unknown as typeof ObservableT
29
26
  type Observable<Ok, Err = never> = ObservableT<Ok, Err>
30
27
 
31
- /* Catch a thrown error and re-throw it on the next tick. The synchronous
32
- * caller keeps going; the error eventually surfaces as an uncaught
33
- * exception. */
34
- function panicAsync(err: unknown): void {
35
- setTimeout(() => {
36
- throw err
37
- }, 0)
38
- }
39
-
40
- /* Map values through a transform. Throws inside `fn` are caught and
41
- * scheduled to re-throw on the next tick (panic). The bad value is dropped
42
- * for that subscription; sibling subscriptions are unaffected. */
28
+ /* Map values through a transform. A throw inside `fn` panics. */
43
29
  export const map =
44
30
  <A, B>(fn: (value: A) => B) =>
45
31
  (source: Observable<A>): Observable<B> =>
46
32
  new Observable<B>((sub) => {
47
33
  const upstream = source.subscribe({
48
- next: (value) => {
49
- let next: B
50
- try {
51
- next = fn(value)
52
- } catch (err) {
53
- panicAsync(err)
54
- return
55
- }
56
- sub.next(next)
57
- },
34
+ next: (value) => sub.next(fn(value)),
58
35
  complete: () => sub.complete(),
59
36
  })
60
37
  sub.addTeardown(() => upstream.unsubscribe())
61
38
  })
62
39
 
63
- /* Pass through values matching `pred`. `pred` errors panic asynchronously. */
40
+ /* Pass through values matching `predicate`. A throw inside it panics. */
64
41
  export const filter =
65
- <A>(pred: (value: A) => boolean) =>
42
+ <A>(predicate: (value: A) => boolean) =>
66
43
  (source: Observable<A>): Observable<A> =>
67
44
  new Observable<A>((sub) => {
68
45
  const upstream = source.subscribe({
69
46
  next: (value) => {
70
- let keep: boolean
71
- try {
72
- keep = pred(value)
73
- } catch (err) {
74
- panicAsync(err)
75
- return
76
- }
77
- if (keep) sub.next(value)
47
+ if (predicate(value)) sub.next(value)
78
48
  },
79
49
  complete: () => sub.complete(),
80
50
  })
81
51
  sub.addTeardown(() => upstream.unsubscribe())
82
52
  })
83
53
 
84
- /* Take at most n values, then complete. n <= 0 completes immediately. */
54
+ /* Take at most `count` values, then complete. count <= 0 completes immediately. */
85
55
  export const take =
86
- (n: number) =>
56
+ (count: number) =>
87
57
  <A>(source: Observable<A>): Observable<A> =>
88
58
  new Observable<A>((sub) => {
89
- if (n <= 0) {
59
+ if (count <= 0) {
90
60
  sub.complete()
91
61
  return
92
62
  }
93
- let remaining = n
63
+ let remaining = count
94
64
  const upstream = source.subscribe({
95
65
  next: (value) => {
96
66
  if (remaining <= 0) return
@@ -123,8 +93,8 @@ export const takeUntil =
123
93
  })
124
94
 
125
95
  /* Run `fn` when the subscription ends for any reason (unsubscribe or
126
- * natural completion). Throws inside `fn` are caught and panic
127
- * asynchronously, so subsequent teardowns still run. RxJS naming. */
96
+ * natural completion). A throw inside `fn` panics; the remaining teardowns
97
+ * still run. RxJS naming. */
128
98
  export const finalize =
129
99
  (fn: () => void) =>
130
100
  <A>(source: Observable<A>): Observable<A> =>
@@ -135,10 +105,6 @@ export const finalize =
135
105
  })
136
106
  sub.addTeardown(() => {
137
107
  upstream.unsubscribe()
138
- try {
139
- fn()
140
- } catch (err) {
141
- panicAsync(err)
142
- }
108
+ fn()
143
109
  })
144
110
  })
@@ -1,4 +1,4 @@
1
- /* mik_observable.cpp — push-shaped composable event stream primitive.
1
+ /* mik_observable.cpp — push-based composable event stream primitive.
2
2
  *
3
3
  * See .claude/plans/observable.md (worktree branch) for the locked design.
4
4
  *
@@ -9,17 +9,44 @@
9
9
  * addTeardown, closed
10
10
  * - Subscription: unsubscribe
11
11
  *
12
- * Error semantics: throws inside observer or operator callbacks (and inside
13
- * teardown callbacks) are caught at the dispatch boundary, isolated to the
14
- * offending subscriber, and re-thrown asynchronously via setTimeout(0). The
15
- * synchronous producer keeps running (sibling subscribers receive the value,
16
- * remaining teardowns run); the bug eventually surfaces as an uncaught
17
- * exception, which the runtime treats as fatal via the existing
18
- * unhandled-rejection halt mechanism. Stream errors are panics.
12
+ * Error semantics: a throw inside an observer, operator, or teardown callback
13
+ * is caught at the dispatch boundary only to report it and apply the app's
14
+ * onPanic policy; it is an application crash like any other uncaught error.
15
+ * Nothing further is delivered: queued entries are dropped, the multicast
16
+ * fan-out stops, from(iterable) stops pulling, and later next/complete calls
17
+ * from the producer's own callback (which JS cannot interrupt mid-function)
18
+ * are no-ops. Teardowns are the exception, since they release resources for
19
+ * work that is already ending; the rest of the chain still runs.
19
20
  *
20
21
  * Producer-setup throws inside the subscribe callback bubble synchronously
21
22
  * to the .subscribe() caller — that's a bug in the producer factory itself,
22
- * not a runtime dispatch event.
23
+ * not a runtime dispatch event. Teardowns registered before the throw still
24
+ * run: no Subscription reaches the caller, so nothing else could ever
25
+ * release what the producer already acquired.
26
+ *
27
+ * Dispatch trampoline: next/complete invoked while a dispatch is already
28
+ * active (i.e. from inside a handler) enqueue onto a per-runtime FIFO
29
+ * instead of recursing; the outermost dispatch drains the queue before
30
+ * returning. Chain length and re-entrant emission therefore cost O(1)
31
+ * stack per delivery — on-device JS stacks are small and quickjs-ng 0.16
32
+ * frames are large enough that a 3-operator chain used to exhaust a 16 KB
33
+ * limit. Two consequences:
34
+ * - Handler code after a sub.next()/sub.complete() call runs before the
35
+ * downstream handler sees that event (FIFO order is preserved).
36
+ * - complete() closes the subscriber at the call site (complete_pending),
37
+ * so closed/no-op semantics stay synchronous while the complete_fn +
38
+ * teardowns run when the queued entry drains.
39
+ * Delivery is what became O(1) stack, not subscription setup or teardown:
40
+ * subscribe() recurses once per chain layer (user JS calling subscribe), and
41
+ * unsubscribe -> run_teardowns -> upstream.unsubscribe() mirrors it on the
42
+ * way out. Both are paid once per subscription rather than once per value,
43
+ * so neither reintroduces a per-value ceiling.
44
+ * Trade-off: what used to be bounded stack growth is now unbounded heap
45
+ * growth. A synchronous producer emitting N values from inside a dispatch
46
+ * buffers N entries (plus the retained values) instead of recursing N deep.
47
+ * That turns a catchable stack overflow into heap pressure, which on device
48
+ * ends in an OOM panic. Left uncapped deliberately: no shipped producer
49
+ * emits unbounded bursts from inside a handler.
23
50
  */
24
51
 
25
52
  #include <cstddef>
@@ -33,6 +60,20 @@ extern "C" {
33
60
  #include "quickjs.h"
34
61
  }
35
62
 
63
+ /* Per-runtime dispatch queue. Entries hold their own references (dup on
64
+ * enqueue, freed after the drained delivery). The queue is only non-empty
65
+ * while a dispatch is on the stack, so runtime teardown never sees values. */
66
+ struct MIKObservableDispatch {
67
+ struct Entry {
68
+ JSValue subscriber;
69
+ JSValue value; /* JS_UNDEFINED for complete entries */
70
+ bool is_complete;
71
+ };
72
+ bool active = false;
73
+ size_t head = 0;
74
+ std::vector<Entry> queue;
75
+ };
76
+
36
77
  namespace {
37
78
 
38
79
  JSClassID observable_class_id;
@@ -46,6 +87,10 @@ struct ObservableData {
46
87
  struct SubscriberData {
47
88
  JSContext* ctx;
48
89
  bool closed;
90
+ /* complete() was called while queued dispatch was active: the subscriber
91
+ * is closed to producers, but complete_fn + teardowns run when the queued
92
+ * complete entry drains. */
93
+ bool complete_pending;
49
94
  /* observer object retained so its props can't be reclaimed mid-dispatch. */
50
95
  JSValue observer;
51
96
  JSValue next_fn;
@@ -61,24 +106,13 @@ struct SubscriptionData {
61
106
 
62
107
  /* ── Helpers ─────────────────────────────────────────────────────── */
63
108
 
64
- /* Re-throws the captured exception from func_data[0]. Used as the
65
- * `setTimeout(callback, 0)` payload in panic_async. */
66
- static JSValue throw_captured(JSContext* ctx, JSValueConst this_val, int argc,
67
- JSValueConst* argv, int magic, JSValue* func_data) {
68
- (void)this_val;
69
- (void)argc;
70
- (void)argv;
71
- (void)magic;
72
- return JS_Throw(ctx, JS_DupValue(ctx, func_data[0]));
73
- }
74
-
75
- /* Escalation for a panic that cannot be scheduled: report through the
76
- * allocation-free native uncaught path and halt, mirroring the
77
- * unhandled-rejection flush. Scheduling fails exactly when the runtime is
78
- * out of stack or memory — the same conditions that caused the throw — and
79
- * silence here turned engine-level failures into silently dropped values.
80
- * Consumes `exception`. */
81
- static void panic_now(JSContext* ctx, JSValue exception) {
109
+ /* An uncaught throw from a subscriber, operator, or teardown callback is an
110
+ * application crash like any other: report it, notify the host error handler,
111
+ * and apply the app's onPanic policy. Reporting happens here rather than
112
+ * through a deferred re-throw because MIK_Loop stops pumping timers once the
113
+ * panic is armed, so a deferred report would never run. Consumes
114
+ * `exception`. */
115
+ static void panic(JSContext* ctx, JSValue exception) {
82
116
  MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
83
117
  if (mik__report_uncaught(ctx, exception, false) && mik_rt) {
84
118
  if (mik_rt->error_handler_fn) {
@@ -89,57 +123,12 @@ static void panic_now(JSContext* ctx, JSValue exception) {
89
123
  JS_FreeValue(ctx, exception);
90
124
  }
91
125
 
92
- /* Catch a thrown error and re-throw it on the next event-loop tick via the
93
- * runtime's setTimeout. The synchronous caller keeps going (sibling
94
- * subscribers receive the value, remaining teardowns run); the eventual
95
- * uncaught throw halts the runtime via the existing unhandled-rejection
96
- * path. Stream errors are panics. If the deferred throw cannot be
97
- * scheduled, escalate via panic_now instead of going silent.
98
- *
99
- * Takes ownership of `exception` — caller must not free after this call. */
100
- static void panic_async(JSContext* ctx, JSValue exception) {
101
- JSValue global = JS_GetGlobalObject(ctx);
102
- JSValue setTimeout = JS_GetPropertyStr(ctx, global, "setTimeout");
103
- JS_FreeValue(ctx, global);
104
- if (!JS_IsFunction(ctx, setTimeout)) {
105
- JS_FreeValue(ctx, setTimeout);
106
- panic_now(ctx, exception);
107
- return;
108
- }
109
- JSValueConst data[1] = {exception};
110
- JSValue thrower = JS_NewCFunctionData(ctx, throw_captured, 0, 0, 1, data);
111
- if (JS_IsException(thrower)) {
112
- JSValue stray = JS_GetException(ctx);
113
- JS_FreeValue(ctx, stray);
114
- JS_FreeValue(ctx, setTimeout);
115
- panic_now(ctx, exception);
116
- return;
117
- }
118
- JSValue zero = JS_NewInt32(ctx, 0);
119
- JSValueConst call_args[2] = {thrower, zero};
120
- JSValue ret = JS_Call(ctx, setTimeout, JS_UNDEFINED, 2, call_args);
121
- JS_FreeValue(ctx, setTimeout);
122
- JS_FreeValue(ctx, thrower);
123
- JS_FreeValue(ctx, zero);
124
- if (JS_IsException(ret)) {
125
- /* setTimeout itself threw — under stack exhaustion it fails the
126
- * same check the dispatch just failed. Escalate. */
127
- JSValue stray = JS_GetException(ctx);
128
- JS_FreeValue(ctx, stray);
129
- panic_now(ctx, exception);
130
- return;
131
- }
132
- JS_FreeValue(ctx, ret);
133
- JS_FreeValue(ctx, exception);
134
- }
135
-
136
- /* Call `fn(argv...)` synchronously; if it throws, schedule the exception
137
- * to re-throw on the next tick. Caller is not informed of the throw. */
126
+ /* Call `fn(argv...)` synchronously; a throw panics. Caller is not informed. */
138
127
  static void run_safely(JSContext* ctx, JSValue fn, int argc, JSValue* argv) {
139
128
  JSValue ret = JS_Call(ctx, fn, JS_UNDEFINED, argc, argv);
140
129
  if (JS_IsException(ret)) {
141
130
  JSValue exc = JS_GetException(ctx);
142
- panic_async(ctx, exc);
131
+ panic(ctx, exc);
143
132
  } else {
144
133
  JS_FreeValue(ctx, ret);
145
134
  }
@@ -153,15 +142,15 @@ static void invoke_safely(JSContext* ctx, JSValueConst this_val, JSAtom method,
153
142
  JSValue ret = JS_Invoke(ctx, this_val, method, argc, argv);
154
143
  if (JS_IsException(ret)) {
155
144
  JSValue exc = JS_GetException(ctx);
156
- panic_async(ctx, exc);
145
+ panic(ctx, exc);
157
146
  } else {
158
147
  JS_FreeValue(ctx, ret);
159
148
  }
160
149
  }
161
150
 
162
- /* Run all registered teardowns in reverse insertion order. Throws are
163
- * scheduled to re-throw async via panic_async, so subsequent teardowns
164
- * still run synchronously. */
151
+ /* Run all registered teardowns in reverse insertion order. A throw panics,
152
+ * but the remaining teardowns still run: they release resources for work
153
+ * that is already ending. */
165
154
  static void run_teardowns(JSContext* ctx, SubscriberData* d) {
166
155
  /* Swap into a local list. If a teardown calls addTeardown synchronously,
167
156
  * the SubscriberData.closed flag is already true so addTeardown fires the
@@ -174,6 +163,66 @@ static void run_teardowns(JSContext* ctx, SubscriberData* d) {
174
163
  }
175
164
  }
176
165
 
166
+ /* ── Dispatch trampoline ────────────────────────────────────────── */
167
+
168
+ static MIKObservableDispatch* dispatch_state(JSContext* ctx) {
169
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
170
+ return mik_rt ? mik_rt->observable_dispatch : nullptr;
171
+ }
172
+
173
+ /* True once a panic is armed. The producer's own callback keeps running (JS
174
+ * cannot be stopped mid-function), so its later next/complete calls have to
175
+ * become no-ops rather than delivering against crashed state. */
176
+ static bool dispatch_stopped(JSContext* ctx) {
177
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
178
+ return mik_rt && MIK_IsStopRequested(mik_rt);
179
+ }
180
+
181
+ /* Deliver a value to the subscriber's next handler. `value` is borrowed. */
182
+ static void deliver_next(JSContext* ctx, SubscriberData* d, JSValue value) {
183
+ if (!JS_IsUndefined(d->next_fn)) {
184
+ run_safely(ctx, d->next_fn, 1, &value);
185
+ }
186
+ }
187
+
188
+ static void deliver_complete(JSContext* ctx, SubscriberData* d) {
189
+ d->complete_pending = false;
190
+ d->closed = true;
191
+ if (!JS_IsUndefined(d->complete_fn)) {
192
+ run_safely(ctx, d->complete_fn, 0, nullptr);
193
+ }
194
+ run_teardowns(ctx, d);
195
+ }
196
+
197
+ /* Drain queued deliveries in FIFO order. Deliveries may enqueue more; the
198
+ * loop keeps going until the queue is empty. Entries whose subscriber closed
199
+ * (unsubscribed) between enqueue and drain are dropped. */
200
+ static void drain(JSContext* ctx, MIKObservableDispatch* ds) {
201
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
202
+ while (ds->head < ds->queue.size()) {
203
+ MIKObservableDispatch::Entry e = ds->queue[ds->head++];
204
+ auto* d = static_cast<SubscriberData*>(JS_GetOpaque(e.subscriber, subscriber_class_id));
205
+ /* A panicked handler means the state these deliveries were queued
206
+ * against may be broken; free the rest without delivering. */
207
+ bool stopped = mik_rt && MIK_IsStopRequested(mik_rt);
208
+ if (d && !d->closed && !stopped) {
209
+ if (e.is_complete) {
210
+ deliver_complete(ctx, d);
211
+ } else {
212
+ deliver_next(ctx, d, e.value);
213
+ }
214
+ }
215
+ JS_FreeValue(ctx, e.subscriber);
216
+ JS_FreeValue(ctx, e.value);
217
+ }
218
+ ds->queue.clear();
219
+ ds->head = 0;
220
+ /* Don't let a one-off burst pin its capacity for the runtime lifetime. */
221
+ if (ds->queue.capacity() > 32) {
222
+ std::vector<MIKObservableDispatch::Entry>().swap(ds->queue);
223
+ }
224
+ }
225
+
177
226
  /* ── Subscriber ─────────────────────────────────────────────────── */
178
227
 
179
228
  static void subscriber_finalizer(JSRuntime* rt, JSValue val) {
@@ -206,11 +255,22 @@ static JSClassDef subscriber_class_def = {
206
255
  static JSValue subscriber_next(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
207
256
  auto* d = static_cast<SubscriberData*>(JS_GetOpaque2(ctx, this_val, subscriber_class_id));
208
257
  if (!d) return JS_EXCEPTION;
209
- if (d->closed) return JS_UNDEFINED;
210
- if (!JS_IsUndefined(d->next_fn)) {
211
- JSValue arg = argc > 0 ? argv[0] : JS_UNDEFINED;
212
- run_safely(ctx, d->next_fn, 1, &arg);
258
+ if (d->closed || d->complete_pending) return JS_UNDEFINED;
259
+ if (dispatch_stopped(ctx)) return JS_UNDEFINED;
260
+ JSValue arg = argc > 0 ? argv[0] : JS_UNDEFINED;
261
+ MIKObservableDispatch* ds = dispatch_state(ctx);
262
+ if (!ds) {
263
+ deliver_next(ctx, d, arg);
264
+ return JS_UNDEFINED;
265
+ }
266
+ if (ds->active) {
267
+ ds->queue.push_back({JS_DupValue(ctx, this_val), JS_DupValue(ctx, arg), false});
268
+ return JS_UNDEFINED;
213
269
  }
270
+ ds->active = true;
271
+ deliver_next(ctx, d, arg);
272
+ drain(ctx, ds);
273
+ ds->active = false;
214
274
  return JS_UNDEFINED;
215
275
  }
216
276
 
@@ -220,12 +280,22 @@ static JSValue subscriber_complete(JSContext* ctx, JSValueConst this_val, int ar
220
280
  (void)argv;
221
281
  auto* d = static_cast<SubscriberData*>(JS_GetOpaque2(ctx, this_val, subscriber_class_id));
222
282
  if (!d) return JS_EXCEPTION;
223
- if (d->closed) return JS_UNDEFINED;
224
- d->closed = true;
225
- if (!JS_IsUndefined(d->complete_fn)) {
226
- run_safely(ctx, d->complete_fn, 0, nullptr);
283
+ if (d->closed || d->complete_pending) return JS_UNDEFINED;
284
+ if (dispatch_stopped(ctx)) return JS_UNDEFINED;
285
+ MIKObservableDispatch* ds = dispatch_state(ctx);
286
+ if (!ds) {
287
+ deliver_complete(ctx, d);
288
+ return JS_UNDEFINED;
227
289
  }
228
- run_teardowns(ctx, d);
290
+ if (ds->active) {
291
+ d->complete_pending = true;
292
+ ds->queue.push_back({JS_DupValue(ctx, this_val), JS_UNDEFINED, true});
293
+ return JS_UNDEFINED;
294
+ }
295
+ ds->active = true;
296
+ deliver_complete(ctx, d);
297
+ drain(ctx, ds);
298
+ ds->active = false;
229
299
  return JS_UNDEFINED;
230
300
  }
231
301
 
@@ -248,7 +318,7 @@ static JSValue subscriber_add_teardown(JSContext* ctx, JSValueConst this_val, in
248
318
  static JSValue subscriber_get_closed(JSContext* ctx, JSValueConst this_val) {
249
319
  auto* d = static_cast<SubscriberData*>(JS_GetOpaque2(ctx, this_val, subscriber_class_id));
250
320
  if (!d) return JS_EXCEPTION;
251
- return JS_NewBool(ctx, d->closed);
321
+ return JS_NewBool(ctx, d->closed || d->complete_pending);
252
322
  }
253
323
 
254
324
  static const JSCFunctionListEntry subscriber_proto_funcs[] = {
@@ -290,7 +360,10 @@ static JSValue subscription_unsubscribe(JSContext* ctx, JSValueConst this_val, i
290
360
  if (!sd) return JS_EXCEPTION;
291
361
  auto* sub = static_cast<SubscriberData*>(JS_GetOpaque(sd->subscriber_value,
292
362
  subscriber_class_id));
293
- if (!sub || sub->closed) return JS_UNDEFINED;
363
+ /* A pending complete owns the close: its queued entry delivers
364
+ * complete_fn + teardowns, matching the recursive-dispatch order where
365
+ * the complete had already run before unsubscribe could. */
366
+ if (!sub || sub->closed || sub->complete_pending) return JS_UNDEFINED;
294
367
  sub->closed = true;
295
368
  /* unsubscribe() is silent — does NOT call observer.complete().
296
369
  * Only natural producer-driven completion fires observer.complete(). */
@@ -380,6 +453,7 @@ static JSValue subscribe_with_callback(JSContext* ctx, JSValueConst subscribe_cb
380
453
  auto* d = new SubscriberData{
381
454
  ctx,
382
455
  false,
456
+ false,
383
457
  observer_dup,
384
458
  next_fn,
385
459
  complete_fn,
@@ -393,9 +467,20 @@ static JSValue subscribe_with_callback(JSContext* ctx, JSValueConst subscribe_cb
393
467
  * unsubscribe handles it. */
394
468
  JSValue cb_result = JS_Call(ctx, subscribe_cb, JS_UNDEFINED, 1, &subscriber_val);
395
469
  if (JS_IsException(cb_result)) {
396
- /* Producer setup threw — bubble up. Mark closed so any deferred
397
- * dispatch back to this subscriber is silently dropped. */
398
- d->closed = true;
470
+ /* Producer setup threw — bubble up, but release whatever it already
471
+ * acquired first. No Subscription reaches the caller, so a teardown
472
+ * skipped here can never run: the handle it would close is
473
+ * unreachable for the rest of the runtime's life. Park the pending
474
+ * exception while the teardowns run, since they are JS calls and
475
+ * must not inherit it, then restore it for the caller.
476
+ * A queued completion is different: that entry owns the close and
477
+ * runs the teardowns when it drains. */
478
+ if (!d->complete_pending) {
479
+ d->closed = true;
480
+ JSValue pending = JS_GetException(ctx);
481
+ run_teardowns(ctx, d);
482
+ JS_Throw(ctx, pending);
483
+ }
399
484
  JS_FreeValue(ctx, subscriber_val);
400
485
  return cb_result;
401
486
  }
@@ -562,6 +647,8 @@ static JSValue from_iterable_subscribe(JSContext* ctx, JSValueConst this_val, in
562
647
  auto* sd = static_cast<SubscriberData*>(
563
648
  JS_GetOpaque(subscriber, subscriber_class_id));
564
649
  if (!sd || sd->closed) break;
650
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
651
+ if (mik_rt && MIK_IsStopRequested(mik_rt)) break;
565
652
 
566
653
  JSValue value = iterator_next(ctx, iterator, &done);
567
654
  if (JS_IsException(value)) {
@@ -831,9 +918,10 @@ static JSValue multicast_emit_next(JSContext* ctx, JSValueConst this_val, int ar
831
918
 
832
919
  JSValue value = argc > 0 ? argv[0] : JS_UNDEFINED;
833
920
  JSAtom next_atom = JS_NewAtom(ctx, "next");
921
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
834
922
  for (auto& s : snapshot) {
835
923
  auto* sd = static_cast<SubscriberData*>(JS_GetOpaque(s, subscriber_class_id));
836
- if (!sd || sd->closed) {
924
+ if (!sd || sd->closed || (mik_rt && MIK_IsStopRequested(mik_rt))) {
837
925
  JS_FreeValue(ctx, s);
838
926
  continue;
839
927
  }
@@ -861,9 +949,10 @@ static JSValue multicast_emit_complete(JSContext* ctx, JSValueConst this_val, in
861
949
  snapshot.swap(m->subscribers);
862
950
 
863
951
  JSAtom complete_atom = JS_NewAtom(ctx, "complete");
952
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
864
953
  for (auto& s : snapshot) {
865
954
  auto* sd = static_cast<SubscriberData*>(JS_GetOpaque(s, subscriber_class_id));
866
- if (sd && !sd->closed) {
955
+ if (sd && !sd->closed && !(mik_rt && MIK_IsStopRequested(mik_rt))) {
867
956
  invoke_safely(ctx, s, complete_atom, 0, nullptr);
868
957
  }
869
958
  JS_FreeValue(ctx, s);
@@ -948,9 +1037,19 @@ static int observable_module_init(JSContext* ctx, JSModuleDef* m) {
948
1037
 
949
1038
  } // namespace
950
1039
 
1040
+ void mik__observable_dispatch_free(MIKRuntime* mik_rt) {
1041
+ delete mik_rt->observable_dispatch;
1042
+ mik_rt->observable_dispatch = nullptr;
1043
+ }
1044
+
951
1045
  JSModuleDef* mik__observable_init(JSContext* ctx) {
952
1046
  JSRuntime* rt = JS_GetRuntime(ctx);
953
1047
 
1048
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
1049
+ if (mik_rt && !mik_rt->observable_dispatch) {
1050
+ mik_rt->observable_dispatch = new MIKObservableDispatch();
1051
+ }
1052
+
954
1053
  /* Class IDs are runtime-scoped; safe to register once per runtime. */
955
1054
  JS_NewClassID(rt, &observable_class_id);
956
1055
  JS_NewClass(rt, observable_class_id, &observable_class_def);
package/src/mikrojs.cpp CHANGED
@@ -416,6 +416,8 @@ void MIK_FreeRuntime(MIKRuntime* mik_rt) {
416
416
  delete mik_rt->timers;
417
417
  mik_rt->timers = nullptr;
418
418
 
419
+ mik__observable_dispatch_free(mik_rt);
420
+
419
421
  /* Destroy the JS engine. */
420
422
  JS_FreeValue(mik_rt->ctx, mik_rt->env_obj);
421
423
  mik_rt->env_obj = JS_UNDEFINED;
@@ -599,6 +601,8 @@ void mik__execute_jobs(JSContext* ctx) {
599
601
  JS_Throw(ctx1, exc);
600
602
  }
601
603
  mik_dump_error(ctx1);
604
+ /* Same rule as every other uncaught throw: panic. */
605
+ if (mik_rt) MIK_Stop(mik_rt);
602
606
  }
603
607
  break;
604
608
  }
@@ -662,6 +666,7 @@ int MIK_Loop(MIKRuntime* mik_rt) {
662
666
  JS_Throw(mik_rt->ctx, exc);
663
667
  }
664
668
  mik_dump_error(mik_rt->ctx);
669
+ MIK_Stop(mik_rt);
665
670
  return 1;
666
671
  }
667
672
  mik__stdin_consume(mik_rt->ctx);
@@ -787,17 +792,12 @@ bool MIK_IsStopRequested(MIKRuntime* mik_rt) {
787
792
 
788
793
  void MIK_Stop(MIKRuntime* mik_rt) {
789
794
  CHECK_NOT_NULL(mik_rt);
790
- /* A throw inside an interactive REPL eval is a user typo, not an app
791
- * crash; the eval path already reports it. Don't request a stop or
792
- * reboot. (Evaluating implies the REPL is active, so this is checked
793
- * before the IsReplActive guard below.) */
794
- if (mik__repl_is_evaluating()) {
795
- return;
796
- }
797
- /* Signal the loop to halt. This is the single place stop_requested is
798
- * set, so the repl-eval gate above can't be bypassed by a caller. Host
799
- * embedders (Node addon) observe it via MIK_Loop's return value; the
800
- * firmware test supervisor reads it via MIK_IsStopRequested. */
795
+ /* REPL-evaluated code gets no exemption: a typo throws synchronously and
796
+ * the eval path reports it without reaching here, so only async fallout
797
+ * from typed code panics. */
798
+ /* The single place stop_requested is set. Host embedders (Node addon)
799
+ * observe it via MIK_Loop's return value; the firmware test supervisor
800
+ * reads it via MIK_IsStopRequested. */
801
801
  mik_rt->stop_requested = true;
802
802
  /* Only firmware (protocol REPL attached) auto-restarts on uncaught
803
803
  * exceptions. Host embedders (Node addon, standalone tests) own their
package/src/timers.cpp CHANGED
@@ -165,7 +165,17 @@ void mik__timers_consume(JSContext* ctx) {
165
165
  if (due_count == 0)
166
166
  return;
167
167
 
168
+ // Timers that ran (or were skipped) this pass; the one-shot cleanup below
169
+ // must not unschedule the ones a panic stopped us from reaching.
170
+ size_t handled = due_count;
171
+
168
172
  for (size_t i = 0; i < due_count; i++) {
173
+ // A panic in an earlier callback means the state these were scheduled
174
+ // against may be broken. Leave the rest for the restart.
175
+ if (MIK_IsStopRequested(mik_rt)) {
176
+ handled = i;
177
+ break;
178
+ }
169
179
  // Re-find the entry — a previous callback may have cleared it
170
180
  auto it = std::find_if(timers->entries.begin(), timers->entries.end(),
171
181
  [&](const MIKTimerEntry& e) { return e.id == due_ids[i]; });
@@ -189,6 +199,9 @@ void mik__timers_consume(JSContext* ctx) {
189
199
  JS_Throw(mik_rt->ctx, exc);
190
200
  }
191
201
  mik_dump_error(mik_rt->ctx);
202
+ /* Applies the app's onPanic policy; the loop above stops before
203
+ * the next due timer. */
204
+ MIK_Stop(mik_rt);
192
205
  }
193
206
  JS_FreeValue(mik_rt->ctx, ret);
194
207
  JS_FreeValue(mik_rt->ctx, func);
@@ -198,7 +211,7 @@ void mik__timers_consume(JSContext* ctx) {
198
211
  }
199
212
 
200
213
  // Remove one-shot timers that are still alive (not already cleared by a callback)
201
- for (size_t i = 0; i < due_count; i++) {
214
+ for (size_t i = 0; i < handled; i++) {
202
215
  auto it = std::find_if(timers->entries.begin(), timers->entries.end(),
203
216
  [&](const MIKTimerEntry& e) { return e.id == due_ids[i]; });
204
217
  if (it != timers->entries.end() && !it->is_interval) {